Merge remote-tracking branch 'origin/main' into poc-invoice-cycle-ref

This commit is contained in:
Damodar Lohani
2025-01-15 07:17:53 +00:00
61 changed files with 1473 additions and 661 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
"e2e:ui": "playwright test tests/e2e --ui"
},
"dependencies": {
"@appwrite.io/console": "1.4.4",
"@appwrite.io/console": "1.4.6",
"@appwrite.io/pink": "0.25.0",
"@appwrite.io/pink-icons": "0.25.0",
"@popperjs/core": "^2.11.8",
+7 -7
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@appwrite.io/console':
specifier: 1.4.4
version: 1.4.4
specifier: 1.4.6
version: 1.4.6
'@appwrite.io/pink':
specifier: 0.25.0
version: 0.25.0
@@ -95,7 +95,7 @@ importers:
version: 6.6.3
'@testing-library/svelte':
specifier: ^5.2.4
version: 5.2.4(svelte@4.2.19)(vite@5.4.11(@types/node@22.9.0)(sass@1.81.0))(vitest@1.6.0(@types/node@22.9.0)(@vitest/ui@1.6.0)(jsdom@22.1.0)(sass@1.81.0))
version: 5.2.4(svelte@4.2.19)(vite@5.4.11(@types/node@22.9.0)(sass@1.81.0))(vitest@1.6.0)
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.5.2(@testing-library/dom@10.4.0)
@@ -199,8 +199,8 @@ packages:
'@analytics/type-utils@0.6.2':
resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==}
'@appwrite.io/console@1.4.4':
resolution: {integrity: sha512-0B7PEHJIi0eS8+WQOs7RNwY+j3gffMTz6DKyvZAEJbe4550UnmTn4i8pxmyaiEVbhlqIVZ+QD8jVrjXqtkPK6Q==}
'@appwrite.io/console@1.4.6':
resolution: {integrity: sha512-oGA5gkecpy9E/fKJkaGn26JP9mgknc2Vn8hS6xhRBhmUPg8Vqlz0vgPpx1EQbPB2C5HWIf4yToSicOH31XGx6Q==}
'@appwrite.io/pink-icons@0.25.0':
resolution: {integrity: sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q==}
@@ -3834,7 +3834,7 @@ snapshots:
'@analytics/type-utils@0.6.2': {}
'@appwrite.io/console@1.4.4': {}
'@appwrite.io/console@1.4.6': {}
'@appwrite.io/pink-icons@0.25.0': {}
@@ -5075,7 +5075,7 @@ snapshots:
lodash: 4.17.21
redent: 3.0.0
'@testing-library/svelte@5.2.4(svelte@4.2.19)(vite@5.4.11(@types/node@22.9.0)(sass@1.81.0))(vitest@1.6.0(@types/node@22.9.0)(@vitest/ui@1.6.0)(jsdom@22.1.0)(sass@1.81.0))':
'@testing-library/svelte@5.2.4(svelte@4.2.19)(vite@5.4.11(@types/node@22.9.0)(sass@1.81.0))(vitest@1.6.0)':
dependencies:
'@testing-library/dom': 10.4.0
svelte: 4.2.19
+1 -2
View File
@@ -8,8 +8,7 @@ Sentry.init({
dsn: 'https://c7ce178bdedd486480317b72f282fd39@o1063647.ingest.us.sentry.io/4504158071422976',
tracesSampleRate: 1,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1,
integrations: [Sentry.replayIntegration()]
replaysOnErrorSampleRate: 0
});
export const handleError: HandleClientError = Sentry.handleErrorWithSentry(
@@ -99,28 +99,14 @@
</div>
<p class="text u-margin-block-start-16">
{#if isScaleDowngrade}
You'll continue using the {currentOrgPlan.name} plan until your current cycle ends on
<span class="u-bold"
>{!selectedPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</span
>. Starting the next cycle, your plan will switch to {selectedPlan.name}, and you'll be
charged
<span class="u-bold">{formatCurrency(grossCost)}</span>
every 30 days.
{:else}
You'll pay
<span class="u-bold">{formatCurrency(estimatedTotal)}</span>
now, with our first billing cycle starting on
<span class="u-bold"
>{!selectedPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</span
>. Once your credits run out, you'll be charged
<span class="u-bold">{formatCurrency(selectedPlan.price)}</span> plus usage fees every 30
days.
{/if}
You'll pay <span class="u-bold">{formatCurrency(estimatedTotal)}</span> now, with your first
billing cycle starting on
<span class="u-bold"
>{!currentPlan.trialDays
? toLocaleDate(billingPayDate.toString())
: toLocaleDate(trialEndDate.toString())}</span
>. Once your credits run out, you'll be charged
<span class="u-bold">{formatCurrency(currentPlan.price)}</span> plus usage fees every 30 days.
</p>
<FormList class="u-margin-block-start-24">
+1 -1
View File
@@ -67,7 +67,7 @@
following rates. Next billing period: {toLocaleDate(nextDate)}.
</p>
{/if}
<Table noStyles>
<Table noStyles noMargin>
<TableHeader>
<TableCellHead>Resource</TableCellHead>
<TableCellHead>Limit</TableCellHead>
+6 -3
View File
@@ -7,12 +7,15 @@
export let noContent = false;
export let isInfo = false;
export let gap = 16;
export let style = null;
export let wrapperStyle = null;
</script>
<li class="collapsible-item" class:is-info={isInfo}>
{#if noContent}
<div class="collapsible-wrapper">
<div class={`collapsible-button u-gap-${gap}`}>
<div class="collapsible-wrapper" style={wrapperStyle}>
<div class={`collapsible-button u-gap-${gap}`} {style}>
<slot />
</div>
</div>
@@ -58,7 +61,7 @@
// TODO: remove once pink is updated
.collapsible-item:not(.is-info) {
.collapsible-wrapper {
padding-left: 0.5rem;
padding: 0;
}
.collapsible-wrapper.is-disabled {
cursor: not-allowed;
+2
View File
@@ -8,6 +8,7 @@
export let show = false;
export let size: 'small' | 'big' | 'huge' = null;
export let icon: string = null;
export let iconNotMobile: boolean = false;
export let state: 'success' | 'warning' | 'error' | 'info' = null;
export let error: string = null;
export let closable = true;
@@ -39,6 +40,7 @@
{#if icon}
<div
class="avatar is-medium"
class:is-not-mobile={iconNotMobile}
class:is-success={state === 'success'}
class:is-warning={state === 'warning'}
class:is-danger={state === 'error'}
+163 -90
View File
@@ -1,63 +1,141 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { app } from '$lib/stores/app';
import { wizard } from '$lib/stores/wizard';
import SupportWizard from '$routes/(console)/supportWizard.svelte';
import { showSupportModal } from '$routes/(console)/wizard/support/store';
import { isCloud } from '$lib/system';
import { isSupportOnline, showSupportModal } from '$routes/(console)/wizard/support/store';
import { trackEvent } from '$lib/actions/analytics';
import { localeTimezoneName, utcHourToLocaleHour } from '$lib/helpers/date';
import { localeShortTimezoneName, utcHourToLocaleHour } from '$lib/helpers/date';
import { upgradeURL } from '$lib/stores/billing';
import { Card } from '$lib/components/index';
import { app } from '$lib/stores/app';
import { currentPlan } from '$lib/stores/organization';
import { isCloud } from '$lib/system';
export let show = false;
export let showHeader = true;
$: hasPremiumSupport = $currentPlan?.premiumSupport ?? false;
$: supportTimings = `${utcHourToLocaleHour('16:00')} - ${utcHourToLocaleHour('00:00')} ${localeTimezoneName()}`;
$: supportTimings = `${utcHourToLocaleHour('16:00')} - ${utcHourToLocaleHour('00:00')} ${localeShortTimezoneName()}`;
type SupportOption = {
cta?: string;
icon: string;
label: string;
link?: string;
description: string;
showSupport: boolean;
};
const supportOptions: SupportOption[] = [
{
showSupport: true,
icon: 'support',
label: 'Premium support',
description: 'Get priority email support from the Appwrite team'
},
{
icon: 'discord',
cta: 'Discord',
showSupport: false,
label: 'Community support',
link: 'https://appwrite.io/discord',
description: 'Get support from our community through Discord'
},
{
icon: 'github',
cta: 'Open issue',
showSupport: false,
label: 'Open GitHub issue',
link: 'https://github.com/appwrite/appwrite/issues/new/choose',
description: 'Report a bug or pitch a new feature'
}
];
const showCloudSupport = (index) => {
return (index === 0 && isCloud) || index > 0;
};
</script>
{#if isCloud}
<section class="drop-section u-grid u-gap-24 u-padding-24">
<div>
<h4 class="eyebrow-heading-3">Premium support</h4>
{#if hasPremiumSupport}
<p class="u-line-height-1-5 u-margin-block-start-8">
Get personalized support from the Appwrite team from <b>{supportTimings}</b>
</p>
{/if}
</div>
{#if !hasPremiumSupport}
<Button
fullWidth
href={$upgradeURL}
on:click={() => {
trackEvent('click_organization_upgrade', {
from: 'button',
source: 'support_menu'
});
}}>
<span class="text">Get Premium support</span>
</Button>
{:else}
<Button
secondary
fullWidth
on:click={() => {
show = false;
$showSupportModal = false;
wizard.start(SupportWizard);
}}>
<span class="text">Contact our Support Team</span>
</Button>
{/if}
</section>
{/if}
<section class="drop-section u-grid u-gap-24 u-padding-24">
<div>
<h4 class="eyebrow-heading-3">Troubleshooting</h4>
<section class="drop-section support-section">
{#if showHeader}
<h4 class="heading-level-6">Support</h4>
{/if}
<div class="u-margin-block-start-8 u-width-full-line">
{#each supportOptions as option, index}
{#if showCloudSupport(index)}
<Card
isTile
class="support-option-card u-flex u-flex-vertical u-gap-16"
style="border-radius: var(--border-radius-small, 8px); padding: 0.65rem;">
<div class="u-flex u-flex-vertical u-gap-4">
<h4 class="body-text-2 u-bold">{option.label}</h4>
<p class="u-line-height-1-5">
{option.description}
</p>
</div>
{#if option.showSupport}
<div class="u-flex u-gap-12 u-cross-center">
{#if !hasPremiumSupport}
<Button
href={$upgradeURL}
on:click={() => {
trackEvent('click_organization_upgrade', {
from: 'button',
source: 'support_menu'
});
}}>
<span class="text">Get Premium support</span>
</Button>
{:else}
<Button
secondary
class="secondary-button"
on:click={() => {
show = false;
$showSupportModal = false;
wizard.start(SupportWizard);
}}>
<span class="text">Contact support</span>
</Button>
{/if}
<div class="u-flex u-gap-6 u-cross-center">
<span
aria-hidden="true"
class="{isSupportOnline()
? 'icon-check-circle u-color-text-success'
: 'icon-x-circle'} u-padding-block-end-1" />
{supportTimings}
</div>
</div>
{:else}
<Button
href={option.link}
external
secondary
class="secondary-button u-flex u-cross-center u-gap-6"
on:click={() => {
trackEvent('click_organization_upgrade', {
from: 'button',
source: 'support_menu'
});
}}>
<span class={`icon-${option.icon}`} />
<span>{option.cta}</span>
</Button>
{/if}
</Card>
{/if}
{/each}
{#if isCloud}
<div class="u-width-full-line">
{#key $app.themeInUse}
<iframe
style="color-scheme: none"
@@ -72,49 +150,44 @@
</iframe>
{/key}
</div>
</div>
{/if}
</section>
<div class="u-flex u-gap-16">
<a
href="https://appwrite.io/docs"
target="_blank"
rel="noopener noreferrer"
class="button is-secondary u-padding-inline-12 u-stretch u-main-center u-gap-4 u-flex-basis-auto">
<span class="icon-book-open" aria-hidden="true" />
<span class="text">Docs</span>
</a>
<a
href="https://github.com/appwrite/appwrite/issues"
aria-label="Open issue on GitHub"
target="_blank"
rel="noopener noreferrer"
class="button is-secondary u-padding-inline-12 u-stretch u-main-center u-gap-4 u-flex-basis-auto">
<span class="icon-github" aria-hidden="true" />
<span class="text">Open issue</span>
</a>
</div>
</section>
<section class="drop-section u-grid u-gap-8 u-padding-24">
<div>
<h4 class="eyebrow-heading-3">Community support</h4>
<p class="text u-margin-block-start-8">Get help from our community</p>
</div>
<ul class="u-flex u-gap-8">
<li>
<Button href="https://github.com/appwrite" text noMargin external ariaLabel="Github">
<span class="icon-github" aria-hidden="true" />
</Button>
</li>
<li>
<Button
href="https://appwrite.io/discord"
round
text
noMargin
external
ariaLabel="Discord">
<span class="icon-discord" aria-hidden="true" />
</Button>
</li>
</ul>
</section>
<style lang="scss">
.support-section {
gap: 1rem;
padding: 1rem;
display: flex;
flex-direction: column;
@media (max-width: 768px) {
gap: 1.25rem;
padding: 0.5rem;
}
}
:global(.u-gap-6) {
gap: 0.375rem;
}
:global(.support-option-card) {
padding: 0.75rem !important;
}
:global(.theme-dark .support-option-card) {
background: var(--color-bgColor-neutral-default, #19191c);
}
:global(.theme-dark .support-option-card .secondary-button) {
background: var(--color-bgColor-neutral-primary, #131315);
}
:global(.theme-light .support-option-card) {
border: 1px solid var(--color-border-neutral, #ededf0);
background: var(--color-bgColor-neutral-default, #fafafb);
}
:global(.theme-light .support-option-card .secondary-button) {
background: var(--color-bgColor-neutral-primary, #fff);
}
</style>
+5 -1
View File
@@ -18,6 +18,7 @@
export let options: {
value: string | boolean | number | null;
label: string;
disabled?: boolean;
}[];
export let isMultiple = false;
export let fullWidth = false;
@@ -100,7 +101,10 @@
<option value={null} disabled selected hidden>{placeholder}</option>
{/if}
{#each options as option}
<option value={option.value} selected={option.value === value}>
<option
value={option.value}
selected={option.value === value}
disabled={option.disabled}>
{option.label}
</option>
{/each}
+8
View File
@@ -73,3 +73,11 @@
<slot />
</div>
{/if}
<style>
/* button and anchor tags seem to have special dimens, this should be auto fixed in pink2. */
:global(.tag):where(button, a) {
--p-tag-height: 2.125rem;
--p-tag-content-height: 2.024rem;
}
</style>
+17
View File
@@ -128,6 +128,23 @@ export const localeTimezoneName = () => {
return dateWithTimezone.split(', ')[1];
};
/**
* This function processes the timezone name using regex to:
*
* 1. Trim and simplify the text for UI display (e.g., IST, UST, etc.).
* 2. Handle cases where `timeZoneName: 'shortGeneric'` returns names like `India Time`,
* which do not fit well in the support modal UI.
* 3. Not using `timeZoneName: 'short'`, as it can display `GMT+-` formatted timezones,
* which may not be ideal.
*
* Refer to:
* https://github.com/appwrite/console/pull/1564#discussion_r1913977798
*/
export const localeShortTimezoneName = () => {
const timezone = localeTimezoneName();
return timezone.match(/[A-Z]/g)?.join('') || timezone;
};
export const isSameDay = (date1: Date, date2: Date) => {
return (
date1.getFullYear() === date2.getFullYear() &&
+191
View File
@@ -0,0 +1,191 @@
const dialingCodes = {
'213': 'Algeria',
'376': 'Andorra',
'244': 'Angola',
'54': 'Argentina',
'374': 'Armenia',
'297': 'Aruba',
'61': 'Australia',
'43': 'Austria',
'994': 'Azerbaijan',
'973': 'Bahrain',
'880': 'Bangladesh',
'375': 'Belarus',
'32': 'Belgium',
'501': 'Belize',
'229': 'Benin',
'975': 'Bhutan',
'591': 'Bolivia',
'387': 'Bosnia and Herzegovina',
'267': 'Botswana',
'55': 'Brazil',
'673': 'Brunei',
'359': 'Bulgaria',
'226': 'Burkina Faso',
'257': 'Burundi',
'855': 'Cambodia',
'237': 'Cameroon',
'1': 'North America',
'238': 'Cape Verde Islands',
'56': 'Chile',
'86': 'China',
'57': 'Colombia',
'269': 'Comoros and Mayotte',
'242': 'Congo',
'682': 'Cook Islands',
'506': 'Costa Rica',
'385': 'Croatia',
'53': 'Cuba',
'357': 'Cyprus',
'420': 'Czech Republic',
'45': 'Denmark',
'253': 'Djibouti',
'593': 'Ecuador',
'20': 'Egypt',
'503': 'El Salvador',
'240': 'Equatorial Guinea',
'291': 'Eritrea',
'372': 'Estonia',
'251': 'Ethiopia',
'500': 'Falkland Islands',
'298': 'Faroe Islands',
'679': 'Fiji',
'358': 'Finland',
'33': 'France',
'594': 'French Guiana',
'689': 'French Polynesia',
'241': 'Gabon',
'220': 'Gambia',
'995': 'Georgia',
'49': 'Germany',
'233': 'Ghana',
'350': 'Gibraltar',
'30': 'Greece',
'299': 'Greenland',
'590': 'Guadeloupe',
'1671': 'Guam',
'502': 'Guatemala',
'224': 'Guinea',
'245': 'Guinea-Bissau',
'592': 'Guyana',
'509': 'Haiti',
'504': 'Honduras',
'852': 'Hong Kong',
'36': 'Hungary',
'354': 'Iceland',
'91': 'India',
'62': 'Indonesia',
'98': 'Iran',
'964': 'Iraq',
'353': 'Ireland',
'972': 'Israel',
'39': 'Italy',
'81': 'Japan',
'962': 'Jordan',
'254': 'Kenya',
'686': 'Kiribati',
'850': 'North Korea',
'82': 'South Korea',
'965': 'Kuwait',
'996': 'Kyrgyzstan',
'856': 'Laos',
'371': 'Latvia',
'961': 'Lebanon',
'266': 'Lesotho',
'231': 'Liberia',
'218': 'Libya',
'423': 'Liechtenstein',
'370': 'Lithuania',
'352': 'Luxembourg',
'853': 'Macao',
'389': 'Macedonia',
'261': 'Madagascar',
'265': 'Malawi',
'60': 'Malaysia',
'960': 'Maldives',
'223': 'Mali',
'356': 'Malta',
'692': 'Marshall Islands',
'596': 'Martinique',
'222': 'Mauritania',
'52': 'Mexico',
'691': 'Micronesia',
'373': 'Moldova',
'377': 'Monaco',
'976': 'Mongolia',
'212': 'Morocco',
'258': 'Mozambique',
'95': 'Myanmar',
'264': 'Namibia',
'674': 'Nauru',
'977': 'Nepal',
'31': 'Netherlands',
'687': 'New Caledonia',
'64': 'New Zealand',
'505': 'Nicaragua',
'227': 'Niger',
'234': 'Nigeria',
'683': 'Niue',
'672': 'Norfolk Islands',
'1670': 'Northern Mariana Islands',
'47': 'Norway',
'968': 'Oman',
'680': 'Palau',
'507': 'Panama',
'675': 'Papua New Guinea',
'595': 'Paraguay',
'51': 'Peru',
'63': 'Philippines',
'48': 'Poland',
'351': 'Portugal',
'974': 'Qatar',
'262': 'Reunion',
'40': 'Romania',
'7': 'Russia, Kazakhstan, Uzbekistan, Turkmenistan, and Tajikistan',
'250': 'Rwanda',
'378': 'San Marino',
'239': 'Sao Tome and Principe',
'966': 'Saudi Arabia',
'221': 'Senegal',
'381': 'Serbia',
'248': 'Seychelles',
'232': 'Sierra Leone',
'65': 'Singapore',
'421': 'Slovak Republic',
'386': 'Slovenia',
'677': 'Solomon Islands',
'252': 'Somalia',
'27': 'South Africa',
'34': 'Spain',
'94': 'Sri Lanka',
'290': 'St. Helena',
'249': 'Sudan',
'597': 'Suriname',
'268': 'Swaziland',
'46': 'Sweden',
'41': 'Switzerland',
'963': 'Syria',
'886': 'Taiwan',
'66': 'Thailand',
'228': 'Togo',
'676': 'Tonga',
'216': 'Tunisia',
'90': 'Turkey',
'688': 'Tuvalu',
'256': 'Uganda',
'380': 'Ukraine',
'971': 'United Arab Emirates',
'44': 'United Kingdom',
'598': 'Uruguay',
'678': 'Vanuatu',
'58': 'Venezuela',
'84': 'Vietnam',
'967': 'Yemen',
'260': 'Zambia',
'255': 'Zanzibar',
'263': 'Zimbabwe'
};
export function getCountryName(diallingCode: string): string {
return dialingCodes[diallingCode] ?? 'Unknown';
}
+13
View File
@@ -0,0 +1,13 @@
import { goto } from '$app/navigation';
import { base } from '$app/paths';
export function checkPricingRefAndRedirect(searchParams: URLSearchParams, shouldRegister = false) {
if (searchParams.has('type')) {
const paramType = searchParams.get('type');
if (paramType === 'createPro') {
shouldRegister
? goto(`${base}/register?type=createPro`)
: goto(`${base}/create-organization?type=createPro`);
}
}
}
+22 -13
View File
@@ -125,17 +125,19 @@
<Feedback />
</svelte:fragment>
</DropList>
{#if isCloud}
<DropList width="18.5" bind:show={showSupport} scrollable={true}>
<Button text on:click={() => (showSupport = !showSupport)}>
<span class="text">Support</span>
</Button>
<svelte:fragment slot="other">
<Support bind:show={showSupport} />
</svelte:fragment>
</DropList>
{/if}
<DropList
class="support-drop-section"
bind:show={showSupport}
scrollable={true}
noArrow
placement="bottom-end">
<Button text on:click={() => (showSupport = !showSupport)}>
<span class="text">Support</span>
</Button>
<svelte:fragment slot="other">
<Support bind:show={showSupport} />
</svelte:fragment>
</DropList>
<Button
actions={[
(node) => {
@@ -156,9 +158,9 @@
{#if $user}
<div class="drop-wrapper" class:is-open={showDropdown} bind:this={droplistElement}>
<button class="user-profile-button" on:click={() => (showDropdown = !showDropdown)}>
<AvatarInitials size={40} name={$user.name} />
<AvatarInitials size={40} name={$user.name || $user.email} />
<span class="user-profile-info is-only-desktop">
<span class="name" data-private>{$user.name}</span>
<span class="name" data-private>{$user.name || $user.email}</span>
{#if $organization}
<span class="title" data-private>{$organization.name}</span>
{/if}
@@ -265,3 +267,10 @@
{/if}
</nav>
</div>
<style>
:global(.support-drop-section) {
width: 28.375rem;
margin-block-start: 0.15rem;
}
</style>
+5
View File
@@ -224,7 +224,11 @@ export type OrganizationUsage = {
executions: number;
bandwidth: number;
users: number;
authPhoneTotal: number;
authPhoneEstimate: number;
}>;
authPhoneTotal: number;
authPhoneEstimate: number;
};
export type AggregationList = {
@@ -311,6 +315,7 @@ export type Plan = {
executions: number;
realtime: number;
logs: number;
authPhone: number;
usage: {
bandwidth: AdditionalResource;
executions: AdditionalResource;
+15
View File
@@ -299,4 +299,19 @@ export type UsageProject = {
* Aggregated statistics of total number of buckets.
*/
bucketsTotal: number;
/**
* Aggregated statistics of total number of SMS sent.
*/
authPhoneTotal: number;
/**
* Aggregated statistics of estimated SMS cost.
*/
authPhoneEstimate: number;
/**
* Aggregated statistics of total number SMS by country
*/
authPhoneCountriesBreakdown: Models.MetricBreakdown[];
};
+3 -2
View File
@@ -128,7 +128,8 @@ export type PlanServices =
| 'teams'
| 'users'
| 'usersAddon'
| 'webhooks';
| 'webhooks'
| 'authPhone';
export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan?: Plan): number {
if (!isCloud) return 0;
@@ -136,7 +137,7 @@ export function getServiceLimit(serviceId: PlanServices, tier: Tier = null, plan
const info = get(plansInfo);
if (!info) return 0;
plan ??= info.get(tier ?? get(organization)?.billingPlan);
return plan?.[serviceId];
return plan?.[serviceId] ?? 0;
}
export const failedInvoice = cachedStore<
+8
View File
@@ -0,0 +1,8 @@
import { page } from '$app/stores';
import type { Models } from '@appwrite.io/console';
import { derived } from 'svelte/store';
export const specificationsList = derived(
page,
async ($page) => (await $page.data.specificationsList) as Models.SpecificationList
);
@@ -0,0 +1,24 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { upgradeURL } from '$lib/stores/billing';
</script>
<div class="u-flex-vertical u-gap-16">
<p>Additional CPU and RAM are available for Pro and Scale teams.</p>
<div class="u-flex u-gap-8 u-cross-center">
<a
class="u-bold u-block"
target="_blank"
rel="noopener noreferrer"
href="https://appwrite.io/docs/advanced/platform/gb-hours">Learn more</a>
<Button href={$upgradeURL} secondary fullWidthMobile event={'specifications_cta_upgrade'}>
<span class="text">Upgrade Plan</span>
</Button>
</div>
</div>
<style>
a {
flex-grow: 1;
}
</style>
+2 -1
View File
@@ -19,7 +19,8 @@
repositoryBehaviour: 'new',
repositoryName: template.id,
repositoryPrivate: true,
repositoryId: null
repositoryId: null,
specification: null
});
wizard.start(CreateTemplate);
}
+12 -1
View File
@@ -37,7 +37,18 @@
undefined,
undefined,
$createFunction.entrypoint,
$createFunction.commands || undefined
$createFunction.commands || undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
$createFunction.specification
);
await sdk.forProject.functions.createDeployment(
response.$id,
@@ -58,7 +58,8 @@
$template.providerRepositoryId || undefined,
$template.providerOwner || undefined,
runtimeDetail.providerRootDirectory || undefined,
$template.providerVersion || undefined
$template.providerVersion || undefined,
$templateConfig.specification || undefined
);
if ($templateConfig.variables) {
@@ -78,7 +79,8 @@
customId: !!response.$id,
runtime: response.runtime,
deployment_type: $templateConfig.repositoryBehaviour,
scopes: $templateConfig.scopes
scopes: $templateConfig.scopes,
specification: $templateConfig.specification
});
resetState();
} catch (error) {
@@ -6,16 +6,30 @@
import { onMount } from 'svelte';
import { createFunction } from '../store';
import { runtimesList } from '$lib/stores/runtimes';
import { specificationsList } from '$lib/stores/specifications';
import { isCloud } from '$lib/system';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
import SpecificationsTooltip from '../components/specificationsTooltip.svelte';
let showCustomId = false;
let options = [];
let specificationOptions = [];
onMount(async () => {
options = (await $runtimesList).runtimes.map((runtime) => ({
label: `${runtime.name} - ${runtime.version}`,
value: runtime.$id
}));
specificationOptions = (await $specificationsList).specifications.map((size) => ({
label:
`${size.cpus} CPU, ${size.memory} MB RAM` +
(!size.enabled ? ` (Upgrade to use this)` : ''),
value: size.slug,
disabled: !size.enabled
}));
});
</script>
@@ -39,6 +53,18 @@
{options}
required />
<InputSelect
label="CPU and memory"
id="specification"
placeholder="Select specification"
required
disabled={specificationOptions.length < 1}
options={specificationOptions}
popover={isCloud && $organization?.billingPlan === BillingPlan.FREE
? SpecificationsTooltip
: null}
bind:value={$createFunction.specification} />
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}>
@@ -4,7 +4,12 @@
import { FormList, InputSelect, InputText } from '$lib/elements/forms';
import { WizardStep } from '$lib/layout';
import { runtimesList } from '$lib/stores/runtimes';
import { specificationsList } from '$lib/stores/specifications';
import { BillingPlan } from '$lib/constants';
import SpecificationsTooltip from '../components/specificationsTooltip.svelte';
import { template, templateConfig } from '../store';
import { organization } from '$lib/stores/organization';
import { isCloud } from '$lib/system';
let showCustomId = false;
@@ -12,6 +17,10 @@
if (!$templateConfig.runtime) {
throw new Error('Please select a runtime.');
}
if (!$templateConfig.specification) {
throw new Error('Please select a specification.');
}
}
async function loadRuntimes() {
@@ -27,6 +36,22 @@
return options;
}
async function loadSpecifications() {
const specificationOptions = (await $specificationsList).specifications.map((size) => ({
label:
`${size.cpus} CPU, ${size.memory} MB RAM` +
(!size.enabled ? ` (Upgrade to use this)` : ''),
value: size.slug,
disabled: !size.enabled
}));
if (!$templateConfig.specification && specificationOptions.length > 0) {
$templateConfig.specification = specificationOptions[0].value;
}
return specificationOptions;
}
</script>
<WizardStep {beforeSubmit}>
@@ -61,6 +86,28 @@
{options}
bind:value={$templateConfig.runtime} />
{/await}
{#await loadSpecifications()}
<InputSelect
label="CPU and memory"
id="specification"
placeholder="Loading specifications..."
required
disabled
options={[]}
value={null} />
{:then specificationOptions}
<InputSelect
label="CPU and memory"
id="specification"
placeholder="Select specification"
required
disabled={specificationOptions.length < 1}
options={specificationOptions}
popover={isCloud && $organization?.billingPlan === BillingPlan.FREE
? SpecificationsTooltip
: null}
bind:value={$templateConfig.specification} />
{/await}
</FormList>
<FormList class="u-margin-block-start-24">
@@ -10,7 +10,10 @@
onMount(() => {
$templateConfig.execute = $template.permissions.includes('any');
templateScopes = scopes
.filter((scope) => $template.scopes.includes(scope.scope))
// hot fix for perplexity chat template,
// backend doesn't have scopes for this template.
// TODO: @itznotabug fix on backend too.
.filter((scope) => ($template.scopes ?? []).includes(scope.scope))
.map((scope) => ({
...scope,
active: true
+1
View File
@@ -15,6 +15,7 @@ export const templateConfig = writable<{
repositoryId: string;
execute?: boolean;
scopes?: string[];
specification?: string;
}>();
export const repository = writable<Models.ProviderRepository>();
export const installation = writable<Models.Installation>();
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -52,7 +52,7 @@
<Cover>
<svelte:fragment slot="header">
<CoverTitle>
{$user.name}
{$user.name || $user.email}
</CoverTitle>
<div class="u-margin-inline-start-auto">
<Button secondary on:click={logout}>Logout</Button>
@@ -28,7 +28,7 @@
const getMemberships = async (teamId: string) => {
const memberships = await sdk.forConsole.teams.listMemberships(teamId);
return memberships.memberships.map((team) => team.userName);
return memberships.memberships.map((team) => team.userName || team.userEmail);
};
function isCloudOrg(
@@ -190,6 +190,11 @@
$: selectedOrg = $organizationList?.teams?.find(
(team) => team.$id === selectedOrgId
) as Organization;
$: billingPlan =
selectedOrg?.billingPlan === BillingPlan.SCALE
? BillingPlan.SCALE
: (campaign?.plan ?? BillingPlan.PRO);
</script>
<svelte:head>
@@ -269,7 +274,7 @@
</div>
</div>
{/if}
{#if selectedOrg?.$id && selectedOrg?.billingPlan !== BillingPlan.FREE}
{#if selectedOrg?.$id && selectedOrg?.billingPlan !== BillingPlan.FREE && selectedOrg?.billingPlan !== BillingPlan.GITHUB_EDUCATION}
<section
class="card u-margin-block-start-24"
style:--p-card-padding="1.5rem"
@@ -88,7 +88,11 @@
billingPlan = plan as BillingPlan;
}
}
if (anyOrgFree) {
if (
anyOrgFree ||
($page.url.searchParams.has('type') &&
$page.url.searchParams.get('type') === 'createPro')
) {
billingPlan = BillingPlan.PRO;
}
if ($page.url.searchParams.has('type')) {
+4 -8
View File
@@ -17,6 +17,7 @@
import { tierToPlan, type Tier, plansInfo } from '$lib/stores/billing';
import { formatCurrency } from '$lib/helpers/numbers';
import { base } from '$app/paths';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
let name: string;
let id: string;
@@ -27,11 +28,11 @@
? [
{
value: BillingPlan.FREE,
label: `${tierToPlan(BillingPlan.FREE).name} - ${formatCurrency($plansInfo.get(BillingPlan.FREE).price)}/month`
label: `${tierToPlan(BillingPlan.FREE).name} - ${formatCurrency($plansInfo.get(BillingPlan.FREE).price)} / month`
},
{
value: BillingPlan.PRO,
label: `${tierToPlan(BillingPlan.PRO).name} - ${formatCurrency($plansInfo.get(BillingPlan.PRO).price)}/month + add-ons`
label: `${tierToPlan(BillingPlan.PRO).name} - ${formatCurrency($plansInfo.get(BillingPlan.PRO).price)} / month + add-ons`
}
// {
// value: BillingPlan.SCALE,
@@ -42,12 +43,7 @@
onMount(() => {
if (isCloud) {
if ($page.url.searchParams.has('type')) {
const paramType = $page.url.searchParams.get('type');
if (paramType === 'createPro') {
goto(`${base}/create-organization`);
}
}
checkPricingRefAndRedirect($page.url.searchParams);
}
});
@@ -32,6 +32,7 @@
import { onMount } from 'svelte';
import { organization } from '$lib/stores/organization';
import { canWriteProjects } from '$lib/stores/roles';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
export let data;
@@ -123,12 +124,7 @@
onMount(async () => {
if (isCloud) {
regions = await sdk.forConsole.billing.listRegions();
if ($page.url.searchParams.has('type')) {
const paramType = $page.url.searchParams.get('type');
if (paramType === 'createPro') {
goto(`${base}/create-organization`);
}
}
checkPricingRefAndRedirect($page.url.searchParams);
}
});
@@ -17,7 +17,6 @@
import { confirmPayment } from '$lib/stores/stripe';
import { sdk } from '$lib/stores/sdk';
import { toLocaleDate } from '$lib/helpers/date';
import { BillingPlan } from '$lib/constants';
import RetryPaymentModal from './retryPaymentModal.svelte';
import { selectedInvoice, showRetryModal } from './store';
import { Button } from '$lib/elements/forms';
@@ -130,9 +129,7 @@
<BillingAddress billingAddress={data?.billingAddress} />
<TaxId />
<BudgetCap />
{#if $organization?.billingPlan !== BillingPlan.FREE && !!$organization?.billingBudget}
<BudgetAlert />
{/if}
<BudgetAlert />
<AvailableCredit />
</Container>
@@ -90,7 +90,9 @@
</script>
<CardGrid hideFooter={$organization?.billingPlan !== BillingPlan.FREE}>
<Heading tag="h2" size="6">Available credit</Heading>
<Heading tag="h2" size="6">
{$organization?.billingPlan === BillingPlan.FREE ? 'Credits' : 'Available credit'}
</Heading>
<p class="text">Appwrite credit will automatically be applied to your next invoice.</p>
<svelte:fragment slot="aside">
@@ -1,8 +1,9 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid, Heading } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Alert, CardGrid, Heading } from '$lib/components';
import { BillingPlan, Dependencies } from '$lib/constants';
import { tierToPlan, upgradeURL } from '$lib/stores/billing';
import { Button, Form, FormList, InputSelectSearch } from '$lib/elements/forms';
import {
Table,
@@ -15,7 +16,7 @@
} from '$lib/elements/table';
import { symmetricDifference } from '$lib/helpers/array';
import { addNotification } from '$lib/stores/notifications';
import { organization } from '$lib/stores/organization';
import { currentPlan, organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
@@ -39,7 +40,7 @@
if (alerts.some((alert) => alert === selectedAlert)) {
return;
}
if (alerts.length <= 2) {
if (alerts.length <= 3) {
alerts = [...alerts, selectedAlert ? selectedAlert : parseInt(search)];
search = '';
selectedAlert = null;
@@ -59,7 +60,7 @@
addNotification({
type: 'success',
isHtml: true,
message: `<span>A budget alert has been added to <b>${$organization.name}</b></span>`
message: `<span> ${alerts.length === 0 ? 'Budget alerts removed from' : alerts.length > 1 ? `Budget alerts added to` : 'A budget alert has been added to'} <b>${$organization.name}</b> </span>`
});
trackEvent(Submit.BudgetAlertsUpdate, {
alerts
@@ -78,65 +79,99 @@
<Form onSubmit={updateBudget}>
<CardGrid>
<Heading tag="h2" size="6">Budget alerts</Heading>
<Heading tag="h2" size="6">Billing alerts</Heading>
<p class="text">
Get notified by email when your organization reaches or exceeds a percent of your
specified budget cap. You can set a maximum of 3 alerts.
{#if !$currentPlan.budgeting}
Get notified by email when your organization meets a percentage of your budget cap. <b
>{tierToPlan($organization.billingPlan).name} organizations will receive one notification
at 75% resource usage.</b>
{:else}
Get notified by email when your organization meets or exceeds a percentage of your
specified billing alert(s).
{/if}
</p>
<svelte:fragment slot="aside">
<FormList>
<div class="u-flex u-gap-16">
<InputSelectSearch
label="Percentage (%) of budget cap"
placeholder="Select a percentage"
id="alerts"
{options}
bind:search
bind:value={selectedAlert}
on:select={() => (search = selectedAlert.toString())} />
<div style="align-self: flex-end">
<Button
secondary
disabled={alerts.length > 2 || (!search && !selectedAlert)}
on:click={addAlert}>
Add alert
</Button>
</div>
</div>
</FormList>
{#if !$currentPlan.budgeting}
<Alert type="info">
<svelte:fragment slot="title"
>Billing alerts are a Pro plan feature
</svelte:fragment>
Upgrade to a Pro plan to manage when you receive billing alerts for your organization.
</Alert>
{:else}
<FormList>
<Alert type="info">
You can set a maximum of 4 billing alerts per organization.
</Alert>
{#if alerts.length}
<Table noMargin noStyles transparent>
<TableHeader>
<TableCellHead>Alert at budget cap %</TableCellHead>
<TableCellHead width={30} />
</TableHeader>
<TableBody>
{#each alerts.sort() as alert}
<TableRow>
<TableCellText title="Percentage">
{alert}%
</TableCellText>
<TableCell>
<Button
text
round
ariaLabel="remove alert"
on:click={() =>
(alerts = alerts.filter((a) => a !== alert))}>
<span class="icon-x" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
<div class="u-flex u-gap-16">
<InputSelectSearch
label="Percentage (%) of budget cap"
placeholder="Select a percentage"
id="alerts"
{options}
bind:search
interactiveOutput
bind:value={selectedAlert}
on:select={() => (search = selectedAlert.toString())} />
<div style="align-self: flex-end">
<Button
secondary
disabled={alerts.length > 3 || (!search && !selectedAlert)}
on:click={addAlert}>
Add alert
</Button>
</div>
</div>
</FormList>
{#if alerts.length}
<Table noMargin noStyles transparent>
<TableHeader>
<TableCellHead>Alert at budget cap %</TableCellHead>
<TableCellHead width={30} />
</TableHeader>
<TableBody>
{#each alerts.sort() as alert}
<TableRow>
<TableCellText title="Percentage">
{alert}%
</TableCellText>
<TableCell>
<Button
text
round
ariaLabel="remove alert"
on:click={() =>
(alerts = alerts.filter((a) => a !== alert))}>
<span class="icon-x" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
{/if}
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={isButtonDisabled} submit>Update</Button>
{#if $organization?.billingPlan === BillingPlan.FREE || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION}
<Button
secondary
href={$upgradeURL}
on:click={() => {
trackEvent('click_organization_upgrade', {
from: 'button',
source: 'billing_alerts_card'
});
}}
>Upgrade to Pro
</Button>
{:else}
<Button disabled={isButtonDisabled} submit>Update</Button>
{/if}
</svelte:fragment>
</CardGrid>
</Form>
@@ -53,10 +53,8 @@
<Heading tag="h2" size="6">Budget cap</Heading>
<p class="text">
Restrict your resource usage by setting a budget cap. <button
on:click={() => ($showUsageRatesModal = true)}
type="button"
class="link">Learn more about usage rates.</button>
Restrict your resource usage by setting a budget cap. Cap usage is reset at the
beginning of each billing cycle.
</p>
<svelte:fragment slot="aside">
{#if !$currentPlan.budgeting}
@@ -76,8 +74,12 @@
<FormList>
<InputSwitch id="cap-active" label="Enable budget cap" bind:value={capActive}>
<svelte:fragment slot="description">
Budget cap limits do not include the base amount of your plan. Cap usage
is reset at the beginning of each billing cycle.
Budget cap limits do not include the base amount of your plan. <button
class="link"
type="button"
on:click={() => ($showUsageRatesModal = true)}
>Learn more about usage rates.
</button>
</svelte:fragment>
</InputSwitch>
{#if capActive}
@@ -102,7 +104,9 @@
from: 'button',
source: 'billing_budget_cap'
});
}}>Upgrade to Pro</Button>
}}
>Upgrade to Pro
</Button>
{:else}
<Button disabled={$organization?.billingBudget === budget} submit>Update</Button>
{/if}
@@ -28,6 +28,8 @@
import { onMount } from 'svelte';
import { trackEvent } from '$lib/actions/analytics';
import { selectedInvoice, showRetryModal } from './store';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
let showDropdown = [];
let showFailedError = false;
@@ -61,135 +63,144 @@
}
</script>
<CardGrid>
<Heading tag="h2" size="6">Payment history</Heading>
{#if $organization?.billingPlan === BillingPlan.FREE && invoiceList.total > 0}
<CardGrid>
<Heading tag="h2" size="6">Payment history</Heading>
<p class="text">
Transaction history for this organization. Download invoices for more details about your
payments.
</p>
<svelte:fragment slot="aside">
{#if invoiceList.total > 0}
<TableScroll noMargin transparent noStyles>
<TableHeader>
<TableCellHead width={100}>Due Date</TableCellHead>
<TableCellHead width={80}>Status</TableCellHead>
<TableCellHead width={100}>Amount Due</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
{#each invoiceList?.invoices as invoice, i}
{@const status = invoice.status}
<TableRow>
<TableCellText title="date">
{toLocaleDate(invoice.dueAt)}
</TableCellText>
<TableCell title="status">
{#if invoice?.lastError}
<DropList bind:show={showFailedError}>
<p class="text">
Transaction history for this organization. Download invoices for more details about your
payments.
</p>
<svelte:fragment slot="aside">
{#if invoiceList.total > 0}
<TableScroll noMargin transparent noStyles>
<TableHeader>
<TableCellHead width={100}>Due Date</TableCellHead>
<TableCellHead width={80}>Status</TableCellHead>
<TableCellHead width={100}>Amount Due</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
{#each invoiceList?.invoices as invoice, i}
{@const status = invoice.status}
<TableRow>
<TableCellText title="date">
{toLocaleDate(invoice.dueAt)}
</TableCellText>
<TableCell title="status">
{#if invoice?.lastError}
<DropList bind:show={showFailedError}>
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' ||
status === 'succeeded'}
warning={status === 'pending'}
on:click={() => (showFailedError = true)}
button>
{status === 'requires_authentication'
? 'failed'
: status}
</Pill>
<svelte:fragment slot="list">
<li>
The scheduled payment has failed.
<Button
link
on:click={() => {
retryPayment(invoice);
showFailedError = false;
}}
>Try again
</Button>
.
</li>
</svelte:fragment>
</DropList>
{:else}
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' || status === 'succeeded'}
warning={status === 'pending'}
on:click={() => (showFailedError = true)}
button>
warning={status === 'pending'}>
{status === 'requires_authentication'
? 'failed'
: status}
</Pill>
<svelte:fragment slot="list">
<li>
The scheduled payment has failed. <Button
link
on:click={() => {
retryPayment(invoice);
showFailedError = false;
}}>Try again</Button
>.
</li>
</svelte:fragment>
</DropList>
{:else}
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' || status === 'succeeded'}
warning={status === 'pending'}>
{status === 'requires_authentication' ? 'failed' : status}
</Pill>
{/if}
</TableCell>
<TableCellText title="due">
{formatCurrency(invoice.grossAmount)}
</TableCellText>
<TableCell showOverflow right>
<DropList
bind:show={showDropdown[i]}
placement="bottom-start"
noArrow>
<Button
round
text
noMargin
ariaLabel="More options"
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<DropListLink
icon="external-link"
external
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/view`}
on:click={() => (showDropdown[i] = !showDropdown[i])}
event="view_invoice">
View invoice
</DropListLink>
<DropListLink
icon="download"
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/download`}
{/if}
</TableCell>
<TableCellText title="due">
{formatCurrency(invoice.grossAmount)}
</TableCellText>
<TableCell showOverflow right>
<DropList
bind:show={showDropdown[i]}
placement="bottom-start"
noArrow>
<Button
round
text
noMargin
ariaLabel="More options"
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}
event="download_invoice">
Download PDF
</DropListLink>
{#if status === 'overdue' || status === 'failed'}
<DropListItem
icon="refresh"
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<DropListLink
icon="external-link"
external
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/view`}
on:click={() =>
(showDropdown[i] = !showDropdown[i])}
event="view_invoice">
View invoice
</DropListLink>
<DropListLink
icon="download"
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/download`}
on:click={() => {
retryPayment(invoice);
showDropdown[i] = !showDropdown[i];
trackEvent(`click_retry_payment`, {
from: 'button',
source: 'billing_invoice_menu'
});
}}>
Retry payment
</DropListItem>
{/if}
</svelte:fragment>
</DropList>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableScroll>
<div class="u-flex u-main-space-between">
<p class="text">Total results: {invoiceList?.total ?? 0}</p>
<PaginationInline {limit} bind:offset sum={invoiceList?.total ?? 0} hidePages />
</div>
{:else}
<EmptySearch hidePagination>
<p class="text u-text-center">
You have no payment history. After you receive your first invoice, you'll see it
here.
</p>
</EmptySearch>
{/if}
</svelte:fragment>
</CardGrid>
}}
event="download_invoice">
Download PDF
</DropListLink>
{#if status === 'overdue' || status === 'failed'}
<DropListItem
icon="refresh"
on:click={() => {
retryPayment(invoice);
showDropdown[i] = !showDropdown[i];
trackEvent(`click_retry_payment`, {
from: 'button',
source: 'billing_invoice_menu'
});
}}>
Retry payment
</DropListItem>
{/if}
</svelte:fragment>
</DropList>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableScroll>
<div class="u-flex u-main-space-between">
<p class="text">Total results: {invoiceList?.total ?? 0}</p>
<PaginationInline {limit} bind:offset sum={invoiceList?.total ?? 0} hidePages />
</div>
{:else}
<EmptySearch hidePagination>
<p class="text u-text-center">
You have no payment history. After you receive your first invoice, you'll
see it here.
</p>
</EmptySearch>
{/if}
</svelte:fragment>
</CardGrid>
{/if}
@@ -99,7 +99,6 @@
<p class="text">View or update your organization payment methods here.</p>
<svelte:fragment slot="aside">
<div class="u-flex u-flex-vertical u-gap-8">
<h4 class="u-bold body-text-2">Default</h4>
{#if $organization?.paymentMethodId}
<CreditCardInfo isBox paymentMethod={defaultPaymentMethod}>
<DropList bind:show={showDropdown} placement="bottom-start" noArrow>
@@ -196,64 +195,65 @@
</article>
{/if}
</div>
<div class="u-flex u-flex-vertical u-gap-8">
<h4 class="u-bold body-text-2">Backup</h4>
{#if $organization?.backupPaymentMethodId}
<CreditCardInfo isBox paymentMethod={backupPaymentMethod}>
<DropList bind:show={showDropdownBackup} placement="bottom-start" noArrow>
<Button
round
text
ariaLabel="More options"
on:click={() => {
showDropdownBackup = !showDropdownBackup;
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
{#if backupPaymentMethod.userId === $user.$id}
{#if $organization?.billingPlan !== BillingPlan.FREE && $organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION}
<div class="u-flex u-flex-vertical u-gap-8">
{#if $organization?.backupPaymentMethodId}
<h4 class="u-bold body-text-2">Backup</h4>
<CreditCardInfo isBox paymentMethod={backupPaymentMethod}>
<DropList bind:show={showDropdownBackup} placement="bottom-start" noArrow>
<Button
round
text
ariaLabel="More options"
on:click={() => {
showDropdownBackup = !showDropdownBackup;
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
{#if backupPaymentMethod.userId === $user.$id}
<DropListItem
icon="pencil"
on:click={() => {
showEdit = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Edit
</DropListItem>
{/if}
<DropListItem
icon="pencil"
icon="switch-horizontal"
on:click={() => {
showEdit = true;
showReplace = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Edit
Replace
</DropListItem>
{/if}
<DropListItem
icon="switch-horizontal"
on:click={() => {
showReplace = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Replace
</DropListItem>
<DropListItem
icon="trash"
on:click={() => {
showDelete = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Delete
</DropListItem>
</svelte:fragment>
</DropList>
</CreditCardInfo>
{:else}
{@const filteredPaymentMethods = $paymentMethods.paymentMethods.filter(
(o) => !!o.last4 && o.$id !== $organization?.paymentMethodId
)}
<article class="card u-grid u-cross-center u-width-full-line dashed">
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<div class="common-section">
<DropList bind:show={showDropdownBackup} placement="bottom-start">
<DropListItem
icon="trash"
on:click={() => {
showDelete = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Delete
</DropListItem>
</svelte:fragment>
</DropList>
</CreditCardInfo>
{:else}
{@const filteredPaymentMethods = $paymentMethods.paymentMethods.filter(
(o) => !!o.last4 && o.$id !== $organization?.paymentMethodId
)}
<div
class="u-bold body-text-2 u-flex u-cross-center u-gap-8 u-padding-block-start-8">
<DropList bind:show={showDropdownBackup} placement="bottom-start">
<div class="u-flex u-gap-8 u-cross-center">
<Button
secondary
round
text
noMargin
on:click={() => {
if (filteredPaymentMethods.length) {
showDropdownBackup = !showDropdownBackup;
@@ -262,46 +262,44 @@
showPayment = true;
}
}}>
<i class="icon-plus" />
<span class="icon-plus" />
<span class="text"> Add a backup payment method </span>
</Button>
<svelte:fragment slot="list">
{#if $paymentMethods.total}
{#each filteredPaymentMethods as paymentMethod}
<DropListItem
on:click={() => {
showDropdownBackup = true;
addBackupPaymentMethod(paymentMethod?.$id);
}}>
<span class="u-flex u-cross-center u-gap-8">
<p class="text">
Card ending in {paymentMethod.last4}
</p>
<CreditCardBrandImage
brand={paymentMethod?.brand} />
</span>
</DropListItem>
{/each}
{/if}
<DropListItem on:click={() => (showPayment = true)}>
Add new payment method
</DropListItem>
</svelte:fragment>
</DropList>
</div>
<div class="common-section u-flex u-cross-center u-gap-4">
<span class="text"> Add a backup payment method </span>
<span
class="icon-info"
style="font-size: var(--icon-size-small)"
use:tooltip={{
content:
'When your default payment method fails, a backup method will be used to pay your invoice automatically'
}} />
</div>
<span
class="icon-info u-cursor-pointer"
style="font-size: var(--icon-size-small)"
use:tooltip={{
content:
'When your default payment method fails, a backup method will be used to pay your invoice automatically'
}} />
</div>
<svelte:fragment slot="list">
{#if $paymentMethods.total}
{#each filteredPaymentMethods as paymentMethod}
<DropListItem
on:click={() => {
showDropdownBackup = true;
addBackupPaymentMethod(paymentMethod?.$id);
}}>
<span class="u-flex u-cross-center u-gap-8">
<p class="text">
Card ending in {paymentMethod.last4}
</p>
<CreditCardBrandImage
brand={paymentMethod?.brand} />
</span>
</DropListItem>
{/each}
{/if}
<DropListItem on:click={() => (showPayment = true)}>
Add new payment method
</DropListItem>
</svelte:fragment>
</DropList>
</div>
</article>
{/if}
</div>
{/if}
</div>
{/if}
</svelte:fragment>
</CardGrid>
@@ -1,9 +1,9 @@
<script lang="ts">
import { base } from '$app/paths';
import { CardGrid, Collapsible, CollapsibleItem, Heading } from '$lib/components';
import { Card, CardGrid, Collapsible, CollapsibleItem, Heading } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { plansInfo, upgradeURL } from '$lib/stores/billing';
import { plansInfo, tierToPlan, upgradeURL } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import type { CreditList, Invoice, Plan } from '$lib/sdk/billing';
import { abbreviateNumber, formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
@@ -36,157 +36,183 @@
<Heading tag="h2" size="6">Payment estimates</Heading>
<p class="text">
A breakdown of your estimated upcoming payment for the current billing period.
A breakdown of your estimated upcoming payment for the current billing period. Totals
displayed exclude accumulated credits and applicable taxes.
</p>
<svelte:fragment slot="aside">
<p class="text u-bold">
Due at: {toLocaleDate($organization?.billingNextInvoiceDate)}
</p>
<Collapsible>
<CollapsibleItem noContent>
<span class="body-text-2"> {currentPlan.name} plan</span>
<div class="body-text-2 u-margin-inline-start-auto">
{isTrial || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION
? formatCurrency(0)
: currentPlan
? formatCurrency(currentPlan?.price)
: ''}
</div>
</CollapsibleItem>
{#if currentPlan.budgeting && extraUsage > 0}
<CollapsibleItem isInfo gap={8}>
<svelte:fragment slot="beforetitle">
<span class="body-text-2"><b>Add-ons</b></span><span class="inline-tag"
>{extraMembers ? extraAddons + 1 : extraAddons}</span>
<div class="icon">
<span class="icon-cheveron-down" aria-hidden="true"></span>
</div>
</svelte:fragment>
<svelte:fragment slot="end">
<Card isTile style="--p-card-padding: 1.5rem;" class="card-only-on-desktop">
<Collapsible>
<div class="u-flex-vertical u-gap-8">
<div class="u-flex">
<span class="body-text-2">
{tierToPlan($organization?.billingPlan)?.name} plan</span>
<div class="body-text-2 u-margin-inline-start-auto">
<b>
{formatCurrency(extraUsage >= 0 ? extraUsage : 0)}
</b>
{isTrial ||
$organization?.billingPlan === BillingPlan.GITHUB_EDUCATION
? formatCurrency(0)
: currentPlan
? formatCurrency(currentPlan?.price)
: ''}
</div>
</svelte:fragment>
<ul>
{#if extraMembers}
<li class="u-flex-vertical u-gap-4 u-padding-block-8">
<div class="u-flex u-gap-4">
<h5 class="body-text-2 u-stretch">Additional members</h5>
<div>
{formatCurrency(
extraMembers *
(currentPlan?.usage?.member?.price ?? 0)
)}
</div>
</div>
<div class="u-flex u-gap-4">
<h5 class="body-text-2 u-stretch u-color-text-offline">
{extraMembers}
</h5>
</div>
</li>
{/if}
{#if currentInvoice?.usage}
{#each currentInvoice.usage as excess, i}
<li
class="u-flex-vertical u-gap-4 {extraMembers
? 'u-padding-block-8'
: 'u-padding-block-start-8'}"
class:u-sep-block-start={i > 0 || extraMembers}>
{#if ['storage', 'bandwidth'].includes(excess.name)}
{@const excessValue = humanFileSize(excess.value)}
<div class="u-flex u-main-space-between">
<h5 class="body-text-2 u-stretch u-capitalize">
{excess.name}
</h5>
{formatCurrency(excess.amount)}
</div>
<h5
class="body-text-2 u-stretch u-color-text-offline"
title={formatNumberWithCommas(excess.value ?? 0) +
'bytes'}>
{excessValue.value ?? 0}{excessValue.unit}
</h5>
{/if}
{#if ['users', 'executions'].includes(excess.name)}
<div class="u-flex u-main-space-between">
<h5 class="body-text-2 u-stretch u-capitalize">
{excess.name}
</h5>
{formatCurrency(excess.amount)}
</div>
<h5
class="body-text-2 u-stretch u-color-text-offline"
title={formatNumberWithCommas(excess.value)}>
{abbreviateNumber(excess.value)}
</h5>
{/if}
</li>
{/each}
{/if}
</ul>
</CollapsibleItem>
{/if}
{#if currentPlan.supportsCredit && availableCredit > 0}
<CollapsibleItem noContent gap={4}>
<span class="body-text-2 u-flex u-cross-center u-gap-2"
><svg
width="16"
height="17"
viewBox="0 0 16 17"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.166 7.93434C14.4784 8.24676 14.4784 8.75329 14.166 9.06571L8.56603 14.6657C8.25361 14.9781 7.74708 14.9781 7.43466 14.6657L1.83466 9.06571C1.67842 8.90947 1.60032 8.70469 1.60034 8.49992V4.50002C1.60034 3.17454 2.67486 2.10002 4.00034 2.10002H8.00056C8.20523 2.10008 8.40987 2.17818 8.56603 2.33434L14.166 7.93434ZM4.00034 5.30002C4.44217 5.30002 4.80034 4.94185 4.80034 4.50002C4.80034 4.05819 4.44217 3.70002 4.00034 3.70002C3.55851 3.70002 3.20034 4.05819 3.20034 4.50002C3.20034 4.94185 3.55851 5.30002 4.00034 5.30002Z"
fill="#00BC5D" />
</svg>
Credits to be applied</span>
<div
class="body-text-2 u-margin-inline-start-auto"
style="color: var(--web-green-500, #10B981)">
-{formatCurrency(
Math.min(availableCredit, currentInvoice?.amount ?? 0)
)}
</div>
</CollapsibleItem>
{/if}
<CollapsibleItem noContent gap={4}>
<span class="body-text-2">Current total (USD)</span>
<span class="tooltip u-cross-center" aria-label="total info">
<span
class="icon-info"
aria-hidden="true"
use:tooltip={{
content:
'Totals displayed are estimates updated every 24 hours and may not accurately reflect your invoice.'
}}></span>
</span>
<div class="body-text-2 u-margin-inline-start-auto">
{$organization?.billingPlan === BillingPlan.FREE ||
$organization?.billingPlan === BillingPlan.GITHUB_EDUCATION
? formatCurrency(0)
: formatCurrency(
Math.max(
(currentInvoice?.amount ?? 0) -
Math.min(availableCredit, currentInvoice?.amount ?? 0),
0
)
)}
{#if currentPlan.budgeting && extraUsage > 0}
<CollapsibleItem gap={8}>
<svelte:fragment slot="beforetitle">
<span class="body-text-2"><b>Add-ons</b></span><span
class="inline-tag"
>{extraMembers ? extraAddons + 1 : extraAddons}</span>
<div class="icon">
<span class="icon-cheveron-down" aria-hidden="true"></span>
</div>
</svelte:fragment>
<svelte:fragment slot="end">
<div class="body-text-2 u-margin-inline-start-auto">
<b>
{formatCurrency(extraUsage >= 0 ? extraUsage : 0)}
</b>
</div>
</svelte:fragment>
<ul>
{#if extraMembers}
<li class="u-flex-vertical u-gap-4 u-padding-block-8">
<div class="u-flex u-gap-4">
<h5 class="body-text-2 u-stretch">
Additional members
</h5>
<div>
{formatCurrency(
extraMembers *
(currentPlan?.addons?.seats?.price ?? 0)
)}
</div>
</div>
<div class="u-flex u-gap-4">
<h5
class="body-text-2 u-stretch u-color-text-offline">
{extraMembers}
</h5>
</div>
</li>
{/if}
{#if currentInvoice?.usage}
{#each currentInvoice.usage as excess, i}
<li
class="u-flex-vertical u-gap-4 {extraMembers
? 'u-padding-block-8'
: 'u-padding-block-start-8'}"
class:u-sep-block-start={i > 0 || extraMembers}>
{#if ['storage', 'bandwidth'].includes(excess.name)}
{@const excessValue = humanFileSize(
excess.value
)}
<div class="u-flex u-main-space-between">
<h5
class="body-text-2 u-stretch u-capitalize">
{excess.name}
</h5>
{formatCurrency(excess.amount)}
</div>
<h5
class="body-text-2 u-stretch u-color-text-offline"
title={formatNumberWithCommas(
excess.value ?? 0
) + 'bytes'}>
{excessValue.value ?? 0}{excessValue.unit}
</h5>
{/if}
{#if ['users', 'executions'].includes(excess.name)}
<div class="u-flex u-main-space-between">
<h5
class="body-text-2 u-stretch u-capitalize">
{excess.name}
</h5>
{formatCurrency(excess.amount)}
</div>
<h5
class="body-text-2 u-stretch u-color-text-offline"
title={formatNumberWithCommas(
excess.value
)}>
{abbreviateNumber(excess.value)}
</h5>
{/if}
</li>
{/each}
{/if}
</ul>
</CollapsibleItem>
{/if}
{#if currentPlan.supportsCredit && availableCredit > 0}
<CollapsibleItem noContent gap={4}>
<span class="body-text-2 u-flex u-cross-center u-gap-2"
><svg
width="16"
height="17"
viewBox="0 0 16 17"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.166 7.93434C14.4784 8.24676 14.4784 8.75329 14.166 9.06571L8.56603 14.6657C8.25361 14.9781 7.74708 14.9781 7.43466 14.6657L1.83466 9.06571C1.67842 8.90947 1.60032 8.70469 1.60034 8.49992V4.50002C1.60034 3.17454 2.67486 2.10002 4.00034 2.10002H8.00056C8.20523 2.10008 8.40987 2.17818 8.56603 2.33434L14.166 7.93434ZM4.00034 5.30002C4.44217 5.30002 4.80034 4.94185 4.80034 4.50002C4.80034 4.05819 4.44217 3.70002 4.00034 3.70002C3.55851 3.70002 3.20034 4.05819 3.20034 4.50002C3.20034 4.94185 3.55851 5.30002 4.00034 5.30002Z"
fill="#00BC5D" />
</svg>
Credits to be applied</span>
<div
class="body-text-2 u-margin-inline-start-auto"
style="color: var(--web-green-500, #10B981)">
-{formatCurrency(
Math.min(availableCredit, currentInvoice?.amount ?? 0)
)}
</div>
</CollapsibleItem>
{/if}
{#if $organization?.billingPlan !== BillingPlan.FREE && $organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION}
<CollapsibleItem
noContent
gap={4}
style="margin-block-start: 1rem; padding-block: unset;"
wrapperStyle="padding-block: unset; padding-inline: unset;">
<span class="body-text-2">Current total (USD)</span>
<span class="tooltip u-cross-center" aria-label="total info">
<span
class="icon-info"
aria-hidden="true"
use:tooltip={{
content:
'Totals displayed are estimates updated every 24 hours and may not accurately reflect your invoice.'
}}></span>
</span>
<div class="body-text-2 u-margin-inline-start-auto">
{formatCurrency(
Math.max(
(currentInvoice?.amount ?? 0) -
Math.min(
availableCredit,
currentInvoice?.amount ?? 0
),
0
)
)}
</div>
</CollapsibleItem>
{/if}
</div>
</CollapsibleItem>
</Collapsible>
</Collapsible>
</Card>
</svelte:fragment>
<svelte:fragment slot="actions">
{#if $organization?.billingPlan === BillingPlan.FREE || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION}
<div class="u-flex u-gap-16 u-flex-wrap">
<div
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
<Button text href={`${base}/organization-${$organization?.$id}/usage`}>
View estimated usage
</Button>
@@ -202,7 +228,8 @@
</Button>
</div>
{:else}
<div class="u-flex u-gap-16 u-flex-wrap">
<div
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
{#if $organization?.billingPlanDowngrade !== null}
<Button text on:click={() => (showCancel = true)}>Cancel change</Button>
{:else}
@@ -227,3 +254,37 @@
</CardGrid>
{/if}
<CancelDowngradeModel bind:showCancel />
<style>
:root {
--billing-card-border-color: hsl(var(--color-neutral-10));
}
:global(.card-only-on-desktop) {
background: hsl(var(--color-neutral-5));
border-radius: var(--corner-radius-medium, 8px);
border: 1px solid var(--billing-card-border-color);
}
:global(.theme-dark .card-only-on-desktop) {
background: #2c2c2f;
--billing-card-border-color: #424248;
}
:global(.card-only-on-desktop .collapsible-item:only-of-type) {
margin-block-start: 0.5rem;
border-block-start: solid 0.0625rem hsl(var(--p-toggle-border-color));
}
@media (max-width: 768px) {
:global(.card-only-on-desktop) {
padding: 0.5rem;
box-shadow: unset;
border-radius: unset;
/* yes, these `!important`s are needed */
border: unset !important;
background: unset !important;
}
}
</style>
@@ -302,9 +302,7 @@
<SelectPlan
bind:billingPlan
anyOrgFree={!!anyOrgFree}
class={anyOrgFree && billingPlan !== BillingPlan.FREE
? 'u-margin-block-start-16'
: ''} />
class="u-margin-block-16" />
{#if isDowngrade}
{#if billingPlan === BillingPlan.FREE}
@@ -1,14 +1,14 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Alert, Modal } from '$lib/components';
import { Modal } from '$lib/components';
import { InputText, InputEmail, Button, FormList } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { createEventDispatcher } from 'svelte';
import { organization } from '$lib/stores/organization';
import { invalidate } from '$app/navigation';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { isCloud, isSelfHosted } from '$lib/system';
import { roles } from '$lib/stores/billing';
@@ -60,22 +60,6 @@
</script>
<Modal title="Invite member" {error} size="big" bind:show={showCreate} onSubmit={create}>
{#if isCloud}
{#if $organization?.billingPlan === BillingPlan.PRO}
<!-- <Alert type="info">
You can add unlimited organization members on the {plan.name} plan for
<b>{formatCurrency(plan.addons.member.price)} each per billing period</b>.
</Alert> -->
<Alert type="info">
New roles are free until 1st January 2025. <a
class="link"
href="https://appwrite.io/docs/advanced/platform/roles"
target="_blank"
rel="noopener noreferrer">Learn more</a
>.
</Alert>
{/if}
{/if}
<FormList>
<InputEmail
required
@@ -58,7 +58,7 @@
} else newOrgModal.set(true);
}
$: avatars = $members.memberships?.map((m) => m.userName) ?? [];
$: avatars = $members.memberships?.map((m) => m.userName || m.userEmail) ?? [];
$: organizationId = $page.params.organization;
$: path = `${base}/organization-${organizationId}`;
$: tabs = [
@@ -42,7 +42,7 @@
}
}
$: avatars = $members.memberships.map((team) => team.userName);
$: avatars = $members.memberships.map((m) => m.userName || m.userEmail);
$: orgProjects = `${$projects.total} ${$projects.total === 1 ? 'project' : 'projects'}`;
$: orgMembers = `${$organization.total} ${$organization.total === 1 ? 'member' : 'members'}`;
</script>
@@ -18,6 +18,8 @@
import { BillingPlan } from '$lib/constants';
import { trackEvent } from '$lib/actions/analytics';
import TotalMembers from './totalMembers.svelte';
import { tooltip } from '$lib/actions/tooltip';
import { formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
export let data;
@@ -143,7 +145,6 @@
<Heading tag="h6" size="7">Users</Heading>
<p class="text">The total number of users across all projects in your organization.</p>
<svelte:fragment slot="aside">
{#if data.organizationUsage.users}
{@const current = data.organizationUsage.usersTotal}
@@ -169,7 +170,7 @@
{
name: 'Users',
data: accumulateFromEndingTotal(
data.organizationUsage.users,
data.usersUsageToDate,
data.organizationUsage.usersTotal
)
}
@@ -377,6 +378,58 @@
{/if}
</svelte:fragment>
</CardGrid>
<CardGrid>
<Heading tag="h6" size="7">Phone OTP</Heading>
<p class="text">
OTPs are billed per SMS message, with rates varying by recipient country. For a detailed
cost breakdown, see the <a
href="https://appwrite.io/docs/advanced/platform/phone-otp"
class="link">pricing page</a
>.
</p>
<p>You will not be charged for Phone OTPs before February 10th.</p>
<svelte:fragment slot="aside">
{#if data.organizationUsage.authPhoneTotal}
<div class="u-flex u-main-space-between">
<p>
<span class="heading-level-4"
>{formatNumberWithCommas(data.organizationUsage.authPhoneTotal)}</span>
<span class="body-text-1 u-bold">OTPs</span>
</p>
<p class="u-flex u-gap-8 u-cross-center">
<span class="u-color-text-offline">Estimated cost</span>
<span class="body-text-2">
{formatCurrency(data.organizationUsage.authPhoneEstimate)}
<span
class="icon-info u-color-text-offline"
use:tooltip={{
content:
'The first 10 messages each month are provided at no cost. Pricing may vary as it depends on telecom rates and vendor agreements.'
}} />
</span>
</p>
</div>
{#if project?.length > 0}
<ProjectBreakdown
projects={project}
metric="authPhoneTotal"
estimate="authPhoneEstimate"
{data} />
{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<span
class="icon-chart-square-bar text-large"
aria-hidden="true"
style="font-size: 32px;" />
<p class="u-bold">No data to show</p>
</div>
</Card>
{/if}
</svelte:fragment>
</CardGrid>
<TotalMembers members={data?.organizationMembers} />
<p class="text common-section u-color-text-gray">
@@ -27,7 +27,9 @@ export const load: PageLoad = async ({ params, parent }) => {
executionsTotal: null,
projects: null,
executionsMBSecondsTotal: null,
buildsMBSecondsTotal: null
buildsMBSecondsTotal: null,
authPhoneTotal: null,
authPhoneEstimate: null
}
};
}
@@ -72,12 +74,15 @@ export const load: PageLoad = async ({ params, parent }) => {
}
}
const usersUsageToDate = usage.users.filter((user) => new Date(user.date) < new Date());
return {
organizationUsage: usage,
projectNames,
invoices,
currentInvoice,
organizationMembers,
plan
plan,
usersUsageToDate
};
};
@@ -10,42 +10,62 @@
TableRowLink,
TableScroll
} from '$lib/elements/table';
import { abbreviateNumber } from '$lib/helpers/numbers';
import { abbreviateNumber, formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import type { OrganizationUsage } from '$lib/sdk/billing';
import { base } from '$app/paths';
import { canSeeProjects } from '$lib/stores/roles';
type Metric = 'users' | 'storage' | 'bandwidth' | 'executions';
type Metric = 'users' | 'storage' | 'bandwidth' | 'executions' | 'authPhoneTotal';
type Estimate = 'authPhoneEstimate';
export let data: PageData;
export let projects: OrganizationUsage['projects'];
export let metric: Metric;
export let estimate: Estimate | undefined = undefined;
function getMetricTitle(metric: Metric): string {
switch (metric) {
case 'authPhoneTotal':
return 'Amount';
default:
return 'Usage';
}
}
function getProjectUsageLink(projectId: string): string {
return `${base}/project-${projectId}/settings/usage`;
}
function groupByProject(metric: Metric): Array<{ projectId: string; usage: number }> {
function groupByProject(
metric: Metric,
estimate?: Estimate
): Array<{ projectId: string; usage: number; estimate?: number }> {
const data = [];
for (const project of projects) {
const usage = project[metric];
if (!usage) {
continue;
}
data.push({
projectId: project.projectId,
usage: usage ?? 0
usage: usage ?? 0,
estimate: estimate ? project[estimate] : undefined
});
}
return data;
}
function format(value: number): string {
const humanized = humanFileSize(value);
switch (metric) {
case 'authPhoneTotal':
return formatNumberWithCommas(value);
case 'executions':
case 'users':
return abbreviateNumber(value);
case 'storage':
case 'bandwidth':
return humanized.value + humanized.unit;
return humanFileSize(value).value + humanFileSize(value).unit;
}
}
</script>
@@ -53,29 +73,42 @@
<Collapsible>
<CollapsibleItem>
<svelte:fragment slot="title">Project breakdown</svelte:fragment>
<TableScroll noMargin style="table-layout: auto">
<TableScroll dense noStyles noMargin style="table-layout: auto">
<TableHeader>
<TableCellHead>Project</TableCellHead>
<TableCellHead>Usage</TableCellHead>
<TableCellHead>{getMetricTitle(metric)}</TableCellHead>
{#if estimate}
<TableCellHead>Estimated cost</TableCellHead>
{/if}
{#if $canSeeProjects}
<TableCellHead />
{/if}
</TableHeader>
<TableBody>
{#each groupByProject(metric).sort((a, b) => b.usage - a.usage) as project}
{#each groupByProject(metric, estimate).sort((a, b) => b.usage - a.usage) as project}
{#if !$canSeeProjects}
<TableRow>
<TableCell title="Project">
{data.projectNames[project.projectId]?.name ?? 'Unknown'}
</TableCell>
<TableCell title="Usage">{format(project.usage)}</TableCell>
<TableCell title={getMetricTitle(metric)}
>{format(project.usage)}</TableCell>
{#if project.estimate}
<TableCell title="Estimated cost"
>{formatCurrency(project.estimate)}</TableCell>
{/if}
</TableRow>
{:else}
<TableRowLink href={getProjectUsageLink(project.projectId)}>
<TableCell title="Project">
{data.projectNames[project.projectId]?.name ?? 'Unknown'}
</TableCell>
<TableCell title="Usage">{format(project.usage)}</TableCell>
<TableCell title={getMetricTitle(metric)}
>{format(project.usage)}</TableCell>
{#if project.estimate}
<TableCell title="Estimated cost"
>{formatCurrency(project.estimate)}</TableCell>
{/if}
<TableCell right={true}>
<span
class="icon-cheveron-right u-cross-child-center ignore-icon-rotate" />
@@ -65,13 +65,19 @@
}
</script>
<Modal {error} size="big" bind:show={showEdit} onSubmit={submit} icon={option?.icon}>
<Modal
{error}
size="big"
bind:show={showEdit}
onSubmit={submit}
icon={option?.icon}
iconNotMobile={option?.type === 'relationship'}>
<svelte:fragment slot="title">
<div class="u-flex u-cross-center u-gap-8">
{option?.name}
{#if option?.type === 'relationship'}
<div class="tag eyebrow-heading-3">
<span class="text u-x-small">Beta</span>
<span class="text u-x-small">Experimental</span>
</div>
{/if}
</div>
@@ -66,13 +66,19 @@
}
</script>
<Modal {error} size="big" bind:show={showCreate} onSubmit={submit} icon={$option?.icon}>
<Modal
{error}
size="big"
bind:show={showCreate}
onSubmit={submit}
icon={$option?.icon}
iconNotMobile={selectedOption === 'Relationship'}>
<svelte:fragment slot="title">
{#if selectedOption === 'Relationship'}
<span class="u-flex u-gap-16 u-cross-center">
{selectedOption}
<div class="tag eyebrow-heading-3">
<span class="text u-x-small">Beta</span>
<span class="text u-x-small">Experimental</span>
</div>
</span>
{:else}
@@ -2,7 +2,7 @@
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/stores';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Modal } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, FormList, InputSelect, InputText } from '$lib/elements/forms';
@@ -11,8 +11,7 @@
import { sdk } from '$lib/stores/sdk';
import { IndexType } from '@appwrite.io/console';
import { isRelationship } from '../document-[document]/attributes/store';
import { indexes, type Attributes } from '../store';
import { collection } from '../store';
import { type Attributes, collection, indexes } from '../store';
import Select from './select.svelte';
export let showCreateIndex = false;
@@ -20,8 +19,8 @@
const databaseId = $page.params.database;
let key = '';
let error: string;
let key = `index_${$indexes.length + 1}`;
let types = [
{ value: IndexType.Key, label: 'Key' },
{ value: IndexType.Unique, label: 'Unique' },
@@ -38,6 +37,17 @@
let attributeList = [{ value: '', order: '' }];
function generateIndexKey() {
let indexKeys = $indexes.map((index) => index.key);
let highestIndex = indexKeys.reduce((max, key) => {
const match = key.match(/^index_(\d+)$/);
return match ? Math.max(max, parseInt(match[1], 10)) : max;
}, indexKeys.length);
return `index_${highestIndex + 1}`;
}
function initialize() {
attributeList = externalAttribute
? [{ value: externalAttribute.key, order: 'ASC' }]
@@ -49,6 +59,7 @@
$: if (showCreateIndex) {
error = null;
initialize();
key = generateIndexKey();
}
$: addAttributeDisabled = !attributeList.at(-1)?.value || !attributeList.at(-1)?.order;
@@ -8,10 +8,11 @@ import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ depends }) => {
depends(Dependencies.FUNCTION_INSTALLATIONS);
const [runtimesList, installations, templatesList] = await Promise.all([
const [runtimesList, installations, templatesList, specificationsList] = await Promise.all([
sdk.forProject.functions.listRuntimes(),
sdk.forProject.vcs.listInstallations([Query.limit(100)]),
sdk.forProject.functions.listTemplates(undefined, undefined, 100)
sdk.forProject.functions.listTemplates(undefined, undefined, 100),
sdk.forProject.functions.listSpecifications()
]);
return {
@@ -19,6 +20,7 @@ export const load: LayoutLoad = async ({ depends }) => {
breadcrumbs: Breadcrumbs,
runtimesList,
installations,
templatesList
templatesList,
specificationsList
};
};
@@ -3,30 +3,46 @@
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid, Heading } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { BillingPlan, Dependencies } from '$lib/constants';
import { Button, Form, FormList } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { func } from '../store';
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
import { specificationsList } from '$lib/stores/specifications';
import { runtimesList } from '$lib/stores/runtimes';
import { isValueOfStringEnum } from '$lib/helpers/types';
import { Runtime } from '@appwrite.io/console';
import { isCloud } from '$lib/system';
import { organization } from '$lib/stores/organization';
import SpecificationsTooltip from '$lib/wizards/functions/components/specificationsTooltip.svelte';
const functionId = $page.params.function;
let runtime: string = null;
let specification: string = null;
let options = [];
let specificationOptions = [];
onMount(async () => {
runtime ??= $func.runtime;
specification ??= $func.specification;
let runtimes = await $runtimesList;
let allowedSpecifications = (await $specificationsList).specifications;
options = runtimes.runtimes.map((runtime) => ({
label: `${runtime.name} - ${runtime.version}`,
value: runtime.$id
}));
specificationOptions = allowedSpecifications.map((size) => ({
label:
`${size.cpus} CPU, ${size.memory} MB RAM` +
(!size.enabled ? ` (Upgrade to use this)` : ''),
value: size.slug,
disabled: !size.enabled
}));
});
async function updateRuntime() {
@@ -51,11 +67,12 @@
$func.providerRepositoryId || undefined,
$func.providerBranch || undefined,
$func.providerSilentMode || undefined,
$func.providerRootDirectory || undefined
$func.providerRootDirectory || undefined,
specification
);
await invalidate(Dependencies.FUNCTION);
addNotification({
message: 'Runtime has been updated',
message: 'Runtime settings have been updated',
type: 'success'
});
trackEvent(Submit.FunctionUpdateName);
@@ -67,6 +84,8 @@
trackError(error, Submit.FunctionUpdateName);
}
}
$: isUpdateButtonEnabled = runtime !== $func?.runtime || specification !== $func?.specification;
</script>
<Form onSubmit={updateRuntime}>
@@ -83,11 +102,22 @@
{options}
required
hideRequired />
<InputSelect
label="CPU and memory"
id="size"
placeholder="Select runtime specification"
bind:value={specification}
options={specificationOptions}
popover={isCloud && $organization?.billingPlan === BillingPlan.FREE
? SpecificationsTooltip
: null}
required
hideRequired />
</FormList>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={runtime === $func.runtime} submit>Update</Button>
<Button disabled={!isUpdateButtonEnabled} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -10,6 +10,8 @@
export let data: PageData;
$: total = data.executionsTotal;
$: count = data.executions;
$: gbHoursTotal = data.executionsMbSecondsTotal / 1000 / 3600;
$: mbSecondsCount = data.executionsMbSeconds;
</script>
<Container>
@@ -47,4 +49,24 @@
]} />
</Card>
{/if}
{#if mbSecondsCount}
<Card>
<Heading tag="h6" size="6">{formatNumberWithCommas(gbHoursTotal)}</Heading>
<p>GB Hours</p>
<div class="u-margin-block-start-16" />
<BarChart
series={[
{
name: 'Count of gbHours over time',
data: [
...mbSecondsCount.map((e) => [
e.date,
Math.ceil((e.value / 1000 / 3600) * 1000) / 1000
])
]
}
]} />
</Card>
{/if}
</Container>
@@ -2,6 +2,7 @@
import { Container } from '$lib/layout';
import { CardGrid, Heading, Card, ProgressBarBig } from '$lib/components';
import {
TableRow,
TableBody,
TableCell,
TableCellHead,
@@ -18,6 +19,10 @@
import { total } from '$lib/layout/usage.svelte';
import { BillingPlan } from '$lib/constants.js';
import { base } from '$app/paths';
import { formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import Collapsible from '$lib/components/collapsible.svelte';
import CollapsibleItem from '$lib/components/collapsibleItem.svelte';
import { getCountryName } from '$lib/helpers/diallingCodes.js';
export let data;
@@ -380,6 +385,70 @@
{/if}
</svelte:fragment>
</CardGrid>
<CardGrid>
<Heading tag="h6" size="7">Phone OTP</Heading>
<p class="text">
Calculated for all Phone OTP sent across your project. Resets at the start of each
billing cycle.
</p>
<p>You will not be charged for Phone OTPs before February 10th.</p>
<svelte:fragment slot="aside">
{#if data.usage.authPhoneTotal}
<div class="u-flex u-main-space-between">
<p>
<span class="heading-level-4"
>{formatNumberWithCommas(data.usage.authPhoneTotal)}</span>
<span class="body-text-1 u-bold">OTPs</span>
</p>
<p class="u-flex u-gap-8 u-cross-center">
<span class="u-color-text-offline">Estimated cost</span>
<span class="body-text-2">
{formatCurrency(data.usage.authPhoneEstimate)}
</span>
</p>
</div>
{#if data.usage.authPhoneCountryBreakdown.length > 0}
<Collapsible>
<CollapsibleItem>
<svelte:fragment slot="title">Region breakdown</svelte:fragment>
<Table noMargin noStyles style="table-layout: auto">
<TableHeader>
<TableCellHead>Region</TableCellHead>
<TableCellHead>Amount</TableCellHead>
<TableCellHead>Estimated cost</TableCellHead>
</TableHeader>
<TableBody>
{#each data.usage.authPhoneCountryBreakdown as phone}
<TableRow>
<TableCell title="Region">
{getCountryName(phone.name)}
</TableCell>
<TableCell title="Usage">
{formatNumberWithCommas(phone.value)}
</TableCell>
<TableCell title="Estimated cost">
{formatCurrency(phone.estimate)}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CollapsibleItem>
</Collapsible>
{/if}
{:else}
<Card isDashed>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<span
class="icon-chart-square-bar text-large"
aria-hidden="true"
style="font-size: 32px;" />
<p class="u-bold">No data to show</p>
</div>
</Card>
{/if}
</svelte:fragment>
</CardGrid>
<p class="text u-text-color-gray u-margin-block-start-16">
Metrics are estimates updated every 24 hours and may not accurately reflect your invoice.
</p>
@@ -5,14 +5,14 @@
import { Button, FormList, InputFile } from '$lib/elements/forms';
import { humanFileSize, sizeToBytes } from '$lib/helpers/sizeConvertion';
import WizardStep from '$lib/layout/wizardStep.svelte';
import { getServiceLimit, tierToPlan, upgradeURL } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { tierToPlan, upgradeURL } from '$lib/stores/billing';
import { organization, currentPlan } from '$lib/stores/organization';
import { isCloud } from '$lib/system';
import { bucket } from '../store';
import { createFile } from './store';
let showCustomId = false;
const service = getServiceLimit('fileSize');
const service = $currentPlan['fileSize'];
</script>
<WizardStep>
+3 -1
View File
@@ -20,6 +20,7 @@
onDestroy(() => {
$supportData = {
message: null,
subject: null,
category: 'general',
file: null
};
@@ -39,8 +40,8 @@
'Content-Type': 'application/json'
},
body: JSON.stringify({
subject: 'support',
email: $user.email,
subject: $supportData.subject,
firstName: $user?.name || 'Unknown',
message: $supportData.message,
tags: ['cloud'],
@@ -75,6 +76,7 @@
function resetData() {
$supportData = {
message: null,
subject: null,
category: 'general',
file: null,
project: null
@@ -5,6 +5,24 @@
export let show: boolean;
</script>
<Modal title="Contact us" bind:show>
<Support />
</Modal>
<div class="mobile-support-dialog-modal">
<Modal title="Support" headerDivider={false} size="small" bind:show>
<div class="inner-modal-wrapper">
<Support showHeader={false} />
</div>
</Modal>
</div>
<style>
:global(.mobile-support-dialog-modal dialog) {
background-color: #fff;
}
:global(.theme-dark .mobile-support-dialog-modal dialog) {
background-color: #131315;
}
.inner-modal-wrapper {
padding-block-start: 0.5rem;
}
</style>
@@ -1,5 +1,5 @@
<script lang="ts">
import { FormList, InputSelect, InputTextarea } from '$lib/elements/forms';
import { FormList, InputSelect, InputText, InputTextarea } from '$lib/elements/forms';
import { WizardStep } from '$lib/layout';
import { supportData } from './store';
import { Pill } from '$lib/elements';
@@ -50,6 +50,13 @@
bind:value={$supportData.project}
{options} />
{/if}
<InputText
label="Subject"
id="subject"
placeholder="What do you need help with?"
bind:value={$supportData.subject}
required />
<InputTextarea
label="Tell us a bit more"
id="message"
@@ -2,6 +2,7 @@ import { writable } from 'svelte/store';
export type SupportData = {
message: string;
subject: string;
category: string;
file?: File | null;
project?: string;
@@ -9,6 +10,7 @@ export type SupportData = {
export const supportData = writable<SupportData>({
message: '',
subject: '',
category: 'general',
file: null
});
@@ -20,6 +20,7 @@
import { isCloud } from '$lib/system';
import { page } from '$app/stores';
import { redirectTo } from '$routes/store';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
export let data;
@@ -52,6 +53,8 @@
$page.url.searchParams.delete('redirect');
if (redirect) {
await goto(`${redirect}${$page.url.search}`);
} else if (isCloud) {
checkPricingRefAndRedirect($page.url.searchParams);
} else {
await goto(`${base}/${$page.url.search ?? ''}`);
}
+6
View File
@@ -10,6 +10,8 @@ import { isCloud } from '$lib/system';
import { base } from '$app/paths';
import type { Account } from '$lib/stores/user';
import type { AppwriteException } from '@appwrite.io/console';
import { isCloud } from '$lib/system';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
export const ssr = false;
@@ -49,6 +51,10 @@ export const load: LayoutLoad = async ({ depends, url, route }) => {
}
if (!isPublicRoute) {
if (isCloud) {
checkPricingRefAndRedirect(url.searchParams, true);
}
redirect(303, withParams(`${base}/login`, url.searchParams));
}
};