mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge remote-tracking branch 'origin/main' into poc-invoice-cycle-ref
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
Generated
+7
-7
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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>
|
||||
@@ -19,7 +19,8 @@
|
||||
repositoryBehaviour: 'new',
|
||||
repositoryName: template.id,
|
||||
repositoryPrivate: true,
|
||||
repositoryId: null
|
||||
repositoryId: null,
|
||||
specification: null
|
||||
});
|
||||
wizard.start(CreateTemplate);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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')) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
+44
-11
@@ -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" />
|
||||
|
||||
+8
-2
@@ -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>
|
||||
|
||||
+8
-2
@@ -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}
|
||||
|
||||
+15
-4
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
+34
-4
@@ -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>
|
||||
|
||||
+22
@@ -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>
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
@@ -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 ?? ''}`);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user