mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge pull request #1108 from appwrite/feat-credit-flow
feat: external credit flow
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
export let collaborators: string[];
|
||||
export let couponData: Partial<Coupon>;
|
||||
export let billingBudget: number;
|
||||
export let fixedCoupon = false; // If true, the coupon cannot be removed
|
||||
|
||||
const today = new Date();
|
||||
const billingPayDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
@@ -30,7 +31,10 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="card u-margin-block-start-32 u-flex u-flex-vertical u-gap-8">
|
||||
<section
|
||||
class="card u-margin-block-start-32 u-flex u-flex-vertical u-gap-8"
|
||||
style:--p-card-padding="1.5rem"
|
||||
style:--p-card-border-radius="var(--border-radius-small)">
|
||||
<span class="u-flex u-main-space-between">
|
||||
<p class="text">{currentPlan.name} plan</p>
|
||||
<p class="text">{formatCurrency(currentPlan.price)}</p>
|
||||
@@ -46,29 +50,32 @@
|
||||
<div class="u-flex u-cross-center u-gap-4">
|
||||
<p class="text">
|
||||
<span class="icon-tag u-color-text-success" aria-hidden="true" />
|
||||
{#if couponData.credits > 100}
|
||||
{#if couponData.credits >= 100}
|
||||
{couponData.code.toUpperCase()}
|
||||
{:else}
|
||||
<span use:tooltip={{ content: couponData.code.toUpperCase() }}
|
||||
>Credits applied</span>
|
||||
<span use:tooltip={{ content: couponData.code.toUpperCase() }}>
|
||||
Credits applied
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text is-only-icon"
|
||||
style="--button-size:1.5rem;"
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
on:click={() =>
|
||||
(couponData = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
})}>
|
||||
<span class="icon-x" aria-hidden="true" />
|
||||
</button>
|
||||
{#if !fixedCoupon}
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text is-only-icon"
|
||||
style="--button-size:1.5rem;"
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
on:click={() =>
|
||||
(couponData = {
|
||||
code: null,
|
||||
status: null,
|
||||
credits: null
|
||||
})}>
|
||||
<span class="icon-x" aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if couponData.credits > 100}
|
||||
{#if couponData.credits >= 100}
|
||||
<p class="inline-tag" use:tooltip={{ content: formatCurrency(couponData.credits) }}>
|
||||
Credits applied
|
||||
</p>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
{/if}
|
||||
<InputSelectSearch
|
||||
id="method"
|
||||
required
|
||||
label="Payment method"
|
||||
placeholder="Select payment method"
|
||||
bind:value
|
||||
@@ -86,12 +87,14 @@
|
||||
{:else}
|
||||
<Card
|
||||
isDashed
|
||||
style="--p-card-padding:1rem; --p-card-bg-color: transparent; --p-card-border-radius: 0.5rem"
|
||||
style="--p-card-padding:0.75rem; --p-card-bg-color: transparent; --p-card-border-radius: 0.5rem"
|
||||
isTile>
|
||||
<div class="u-flex u-main-space-between u-cross-center">
|
||||
<p>
|
||||
<span class="icon-exclamation-circle"></span>
|
||||
<span class="text">No saved payment methods</span>
|
||||
<p class="u-flex u-gap-8 u-cross-center">
|
||||
<span
|
||||
class="icon-exclamation u-color-text-warning"
|
||||
style:font-size="var(--icon-size-medium)"></span>
|
||||
<span class="text">A payment method is required</span>
|
||||
</p>
|
||||
<Button secondary on:click={() => (showPaymentModal = true)}>
|
||||
<span class="icon-plus"></span> <span class="text">Add</span>
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
export let id: string = null;
|
||||
let classes = '';
|
||||
export { classes as class };
|
||||
export let style = '';
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={tag}
|
||||
class={`heading-level-${size} u-min-width-0 ${classes}`}
|
||||
class:u-trim-1={trimmed}
|
||||
{style}
|
||||
{id}>
|
||||
<slot />
|
||||
</svelte:element>
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
export let label: string | undefined = undefined;
|
||||
export let optionalText: string | undefined = undefined;
|
||||
export let showLabel = true;
|
||||
export let value: string | number | boolean;
|
||||
export let value: string | number | boolean | null;
|
||||
export let placeholder = '';
|
||||
export let required = false;
|
||||
export let hideRequired = false;
|
||||
export let disabled = false;
|
||||
export let wrapperTag: FormItemTag = 'li';
|
||||
export let options: {
|
||||
value: string | boolean | number;
|
||||
value: string | boolean | number | null;
|
||||
label: string;
|
||||
}[];
|
||||
export let isMultiple = false;
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
/**
|
||||
* Allow form submit and tab input switch
|
||||
*/
|
||||
if (value === '' && ['Enter', 'Tab'].includes(e.key)) {
|
||||
if (value === '' && ['Enter', 'Tab', ','].includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (['Enter', 'Tab', ' '].includes(e.key)) {
|
||||
if (['Enter', 'Tab', ' ', ','].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (validityRegex && !validityRegex.test(value)) {
|
||||
error = validityMessage ? validityMessage : 'Invalid value';
|
||||
@@ -51,6 +51,10 @@
|
||||
};
|
||||
|
||||
const addValue = () => {
|
||||
if (validityRegex && !validityRegex.test(value) && !!value) {
|
||||
error = validityMessage ? validityMessage : 'Invalid value';
|
||||
return;
|
||||
}
|
||||
let tag = value.trim();
|
||||
if (tag.length === 0 || tags.includes(tag)) return;
|
||||
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { Heading } from '$lib/components';
|
||||
import AppwriteLogoDark from '$lib/images/appwrite-logo-dark.svg';
|
||||
import AppwriteLogoLight from '$lib/images/appwrite-logo-light.svg';
|
||||
import LoginDark from '$lib/images/login/login-dark-mode.png';
|
||||
import LoginLight from '$lib/images/login/login-light-mode.png';
|
||||
import type { Coupon } from '$lib/sdk/billing';
|
||||
import { app } from '$lib/stores/app';
|
||||
import { campaigns } from '$lib/stores/campaigns';
|
||||
import { user } from '$lib/stores/user';
|
||||
|
||||
export const imgLight = LoginLight;
|
||||
export const imgDark = LoginDark;
|
||||
|
||||
export let coupon: Coupon = null;
|
||||
|
||||
$: variation = coupon?.code ? 'card' : 'default';
|
||||
$: campaign = campaigns.get(coupon?.campaign);
|
||||
</script>
|
||||
|
||||
<main class="grid-1-1 is-full-page" id="main">
|
||||
<section
|
||||
class="u-flex u-flex-vertical"
|
||||
style:--url={`url(${$app.themeInUse === 'dark' ? imgDark : imgLight})`}>
|
||||
<div class="logo u-flex u-gap-16 is-not-mobile">
|
||||
class={`u-flex u-flex-vertical ${variation === 'card' ? 'u-cross-center u-main-center u-height-100-percent u-width-full-line side-bg' : 'side-default'}`}
|
||||
style:--url={variation === 'card'
|
||||
? ''
|
||||
: `url(${$app.themeInUse === 'dark' ? imgDark : imgLight})`}>
|
||||
{#if variation === 'card'}
|
||||
<div class="side-bg-container" />
|
||||
{/if}
|
||||
<div
|
||||
class="logo u-flex u-gap-16"
|
||||
class:is-not-mobile={variation === 'default'}
|
||||
class:logo-variation={variation === 'card'}>
|
||||
<a href={user ? '/console' : '/'}>
|
||||
{#if $app.themeInUse === 'dark'}
|
||||
<img
|
||||
@@ -32,20 +48,58 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="u-flex u-stretch" />
|
||||
{#if variation === 'default'}
|
||||
<div class="u-flex u-stretch" />
|
||||
|
||||
<div class="tag-line is-not-mobile">
|
||||
<p>Build like a team of hundreds<span class="underscore">_</span></p>
|
||||
</div>
|
||||
<div class="tag-line is-not-mobile">
|
||||
<p>Build like a team of hundreds<span class="underscore">_</span></p>
|
||||
</div>
|
||||
{:else if variation === 'card'}
|
||||
<div
|
||||
class="u-flex u-flex-vertical u-main-center u-cross-center u-height-100-percent u-width-full-line">
|
||||
<img
|
||||
src={`/images/campaigns/${coupon.campaign}/${$app.themeInUse}.png`}
|
||||
class="u-block u-image-object-fit-cover side-bg-img"
|
||||
alt="promo" />
|
||||
|
||||
<div class="u-text-center">
|
||||
<div class="is-only-mobile u-width-full-line">
|
||||
<Heading
|
||||
size="5"
|
||||
tag="h3"
|
||||
class="u-margin-block-start-16"
|
||||
trimmed={false}
|
||||
style="font-weight:normal">
|
||||
{campaign.title.replace('VALUE', coupon.credits)}
|
||||
</Heading>
|
||||
</div>
|
||||
<div class="is-not-mobile u-width-full-line">
|
||||
<Heading
|
||||
size="4"
|
||||
tag="h3"
|
||||
class="u-margin-block-start-32"
|
||||
trimmed={false}
|
||||
style="font-weight:normal">
|
||||
{campaign.title.replace('VALUE', coupon.credits)}
|
||||
</Heading>
|
||||
</div>
|
||||
<p class="u-margin-block-start-16 auth-container">
|
||||
{campaign.description.replace('VALUE', coupon.credits)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<section class="grid-1-1-col-2 u-flex u-flex-vertical u-cross-center u-main-center">
|
||||
<slot name="top" />
|
||||
<div class="container u-flex u-flex-vertical u-cross-center u-main-center">
|
||||
<div class="form-container u-width-full-line">
|
||||
<div class="auth-container u-width-full-line">
|
||||
<h1 class="heading-level-4 u-margin-block-start-auto is-not-mobile">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<h1 class="heading-level-5 u-margin-block-start-auto is-only-mobile">
|
||||
<h1
|
||||
class="heading-level-5 u-margin-block-start-auto is-only-mobile"
|
||||
class:u-padding-block-start-24={variation === 'card'}>
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<div class="u-margin-block-start-24">
|
||||
@@ -82,9 +136,86 @@
|
||||
|
||||
<style lang="scss">
|
||||
@import '@appwrite.io/pink/src/abstract/variables/_devices.scss';
|
||||
@import '@appwrite.io/pink/src/abstract/variables/_common.scss';
|
||||
|
||||
.side-bg {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-block: 2rem;
|
||||
padding-inline: 1rem;
|
||||
background-color: #ededf0 !important;
|
||||
|
||||
&-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
z-index: -1;
|
||||
&::before {
|
||||
position: absolute;
|
||||
inset-block-start: -200px;
|
||||
inset-inline-end: -50px;
|
||||
content: '';
|
||||
display: block;
|
||||
inline-size: 32%;
|
||||
block-size: 32%;
|
||||
background: radial-gradient(49.55% 43.54% at 47% 50.69%, #e7f8f7 0%, #85dbd8 100%);
|
||||
filter: blur(150px);
|
||||
@media #{$break1} {
|
||||
filter: blur(100px);
|
||||
}
|
||||
}
|
||||
&::after {
|
||||
position: absolute;
|
||||
inset-block-end: -50px;
|
||||
inset-inline-start: -50px;
|
||||
content: '';
|
||||
display: block;
|
||||
inline-size: 30%;
|
||||
block-size: 30%;
|
||||
background: radial-gradient(
|
||||
50% 46.73% at 50% 53.27%,
|
||||
#fe9567 28.17%,
|
||||
#fd366e 59.38%
|
||||
);
|
||||
filter: blur(200px);
|
||||
|
||||
@media #{$break1} {
|
||||
filter: blur(100px);
|
||||
}
|
||||
}
|
||||
}
|
||||
&-img {
|
||||
padding-inline: 6.25rem;
|
||||
max-width: 40rem;
|
||||
@media #{$break2} {
|
||||
max-width: 28rem;
|
||||
}
|
||||
@media #{$break1} {
|
||||
margin-block-start: 3rem;
|
||||
max-inline-size: 23rem;
|
||||
padding-inline: 4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
:global(.theme-dark) .side-bg {
|
||||
background-color: #131315 !important;
|
||||
}
|
||||
|
||||
:global(.theme-dark) .side-bg-container::before {
|
||||
inline-size: 20%;
|
||||
block-size: 20%;
|
||||
filter: blur(180px);
|
||||
}
|
||||
:global(.theme-dark) .side-bg-container::after {
|
||||
filter: blur(250px);
|
||||
}
|
||||
|
||||
/* Default (including mobile) */
|
||||
#main section:first-child {
|
||||
.side-default {
|
||||
padding-block-start: 2.25rem;
|
||||
padding-block-end: 2rem;
|
||||
|
||||
@@ -109,7 +240,7 @@
|
||||
|
||||
/* for smaller screens */
|
||||
@media #{$break2open} {
|
||||
#main section:first-child {
|
||||
.side-default {
|
||||
background: var(--url);
|
||||
background-repeat: no-repeat;
|
||||
background-position: top;
|
||||
@@ -127,7 +258,7 @@
|
||||
|
||||
/* for larger screens */
|
||||
@media #{$break3open} {
|
||||
#main section:first-child {
|
||||
.side-default {
|
||||
div {
|
||||
padding-inline-start: 5.625rem;
|
||||
padding-inline-end: 5rem;
|
||||
@@ -152,7 +283,17 @@
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
.auth-container {
|
||||
max-inline-size: 27.5rem;
|
||||
}
|
||||
.logo-variation {
|
||||
padding-block-start: 2rem;
|
||||
|
||||
@media #{$break1} {
|
||||
padding-block-start: 0rem;
|
||||
& img {
|
||||
scale: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
onSubmit={handleSubmit}
|
||||
icon="exclamation"
|
||||
state="warning"
|
||||
size="small"
|
||||
headerDivider={false}>
|
||||
<p>
|
||||
Are you sure you want to exit from <slot />? All data will be deleted. This action is
|
||||
irreversible.
|
||||
<p class="text">
|
||||
<slot />
|
||||
</p>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button text on:click={() => (show = false)}>Cancel</Button>
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import WizardExitModal from './wizardExitModal.svelte';
|
||||
|
||||
export let showExitModal = false;
|
||||
export let href = '';
|
||||
</script>
|
||||
|
||||
<section class="wizard-secondary c-wizard-position">
|
||||
<div class="wizard-secondary-container">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if showExitModal}
|
||||
<WizardExitModal
|
||||
{href}
|
||||
bind:show={showExitModal}
|
||||
on:exit={() => {
|
||||
trackEvent('wizard_exit', {
|
||||
from: 'prompt'
|
||||
});
|
||||
}}>
|
||||
<slot name="exit">
|
||||
Are you sure you want to exit from this process? All data will be deleted. This action
|
||||
is irreversible.
|
||||
</slot>
|
||||
</WizardExitModal>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.c-wizard-position {
|
||||
position: fixed;
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { Heading } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import WizardExitModal from './wizardExitModal.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
let show = false;
|
||||
export let showExitModal = false;
|
||||
export let href: string;
|
||||
export let confirmExit = false;
|
||||
export let href = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<header class="wizard-secondary-header">
|
||||
@@ -15,12 +16,11 @@
|
||||
<Button
|
||||
text
|
||||
round
|
||||
class="u-margin-block-start-8"
|
||||
ariaLabel="close modal"
|
||||
href={showExitModal ? null : href}
|
||||
href={confirmExit ? null : href}
|
||||
on:click={() => {
|
||||
if (showExitModal) {
|
||||
show = true;
|
||||
if (confirmExit) {
|
||||
dispatch('exit');
|
||||
} else {
|
||||
trackEvent('wizard_exit', {
|
||||
from: 'button'
|
||||
@@ -34,16 +34,3 @@
|
||||
<p class="body-text-2"><slot name="description" /></p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if show}
|
||||
<WizardExitModal
|
||||
{href}
|
||||
bind:show
|
||||
on:exit={() => {
|
||||
trackEvent('wizard_exit', {
|
||||
from: 'prompt'
|
||||
});
|
||||
}}>
|
||||
<slot name="exit">this process</slot>
|
||||
</WizardExitModal>
|
||||
{/if}
|
||||
|
||||
@@ -64,6 +64,7 @@ export type Coupon = {
|
||||
expiration: string;
|
||||
status: string; // 'active' | 'disabled' | 'expired'
|
||||
validity: number;
|
||||
campaign?: string;
|
||||
};
|
||||
|
||||
export type Credit = {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//campaign welcome and startup
|
||||
|
||||
export const campaigns = new Map();
|
||||
|
||||
campaigns
|
||||
.set('welcome', {
|
||||
title: "You've received $VALUE in credits",
|
||||
description:
|
||||
'Get $VALUE in credits when you upgrade or create an organization with a Pro plan.'
|
||||
})
|
||||
.set('startup', {
|
||||
title: 'Welcome to the Startups program!',
|
||||
description:
|
||||
"We're excited to have you on board. Add your credit code to your Appwrite Pro account to join."
|
||||
})
|
||||
.set('RenderATL2024', {
|
||||
title: 'Claim your RenderATL $100 credits',
|
||||
description:
|
||||
'Get $100 in Cloud credits when you upgrade or create an organization with a Pro plan.'
|
||||
});
|
||||
@@ -12,9 +12,9 @@
|
||||
import Loading from './loading.svelte';
|
||||
import { loading, requestedMigration } from './store';
|
||||
import { parseIfString } from '$lib/helpers/object';
|
||||
import Consent from '$lib/components/consent.svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { campaigns } from '$lib/stores/campaigns';
|
||||
|
||||
onMount(async () => {
|
||||
// handle sources
|
||||
@@ -75,6 +75,22 @@
|
||||
const pathname = $page.url.pathname;
|
||||
const user = $page.data.account as Models.User<Record<string, string>>;
|
||||
|
||||
const code = $page.url.searchParams.get('code');
|
||||
if (code) {
|
||||
try {
|
||||
const couponData = await sdk.forConsole.billing.getCoupon(code);
|
||||
if (couponData?.campaign && campaigns.has(couponData.campaign)) {
|
||||
if (user) {
|
||||
goto(`${base}/console/apply-credit?code=${code}`);
|
||||
loading.set(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRedirect(pathname, authenticationRoutes)) {
|
||||
if (user?.$id) {
|
||||
if (shouldRedirect(pathname, acceptedAuthenticatedRoutes)) {
|
||||
@@ -122,9 +138,9 @@
|
||||
</script>
|
||||
|
||||
<Notifications />
|
||||
{#if isCloud}
|
||||
<!-- {#if isCloud}
|
||||
<Consent />
|
||||
{/if}
|
||||
{/if} -->
|
||||
|
||||
<slot />
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, goto, invalidate } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { EstimatedTotalBox, SelectPaymentMethod } from '$lib/components/billing';
|
||||
import { BillingPlan, Dependencies } from '$lib/constants';
|
||||
import { Button, Form, FormList, InputSelect, InputTags, InputText } from '$lib/elements/forms';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import {
|
||||
WizardSecondaryContainer,
|
||||
WizardSecondaryContent,
|
||||
WizardSecondaryFooter,
|
||||
WizardSecondaryHeader
|
||||
} from '$lib/layout';
|
||||
import { type PaymentList } from '$lib/sdk/billing';
|
||||
import { app } from '$lib/stores/app';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { organizationList, type Organization } from '$lib/stores/organization';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { ID } from '@appwrite.io/console';
|
||||
import { onMount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export let data;
|
||||
|
||||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;
|
||||
let previousPage: string = `${base}/console`;
|
||||
let showExitModal = false;
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
if (from?.url?.pathname) {
|
||||
if (from.url.pathname.includes('/login') || from.url.pathname.includes('/register')) {
|
||||
previousPage = `${base}/console`;
|
||||
} else {
|
||||
previousPage = from?.url?.pathname || previousPage;
|
||||
}
|
||||
} else {
|
||||
previousPage = `${base}/console`;
|
||||
}
|
||||
});
|
||||
|
||||
let selectedOrgId: string = null;
|
||||
let formComponent: Form;
|
||||
let isSubmitting = writable(false);
|
||||
let methods: PaymentList;
|
||||
let paymentMethodId: string;
|
||||
let collaborators: string[];
|
||||
let taxId: string;
|
||||
let billingBudget: number;
|
||||
let newOrgId = ID.unique();
|
||||
let options = [
|
||||
...($organizationList?.teams?.map((team) => ({
|
||||
value: team.$id,
|
||||
label: team.name
|
||||
})) ?? []),
|
||||
{
|
||||
value: newOrgId,
|
||||
label: 'Create new organization'
|
||||
}
|
||||
];
|
||||
let name: string;
|
||||
|
||||
onMount(async () => {
|
||||
await loadPaymentMethods();
|
||||
if (!$organizationList?.total) {
|
||||
selectedOrgId = newOrgId;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPaymentMethods() {
|
||||
const methodList = await sdk.forConsole.billing.listPaymentMethods();
|
||||
const filteredMethods = methodList.paymentMethods.filter((method) => !!method?.last4);
|
||||
methods = { paymentMethods: filteredMethods, total: filteredMethods.length };
|
||||
paymentMethodId =
|
||||
selectedOrg?.paymentMethodId ??
|
||||
methods.paymentMethods.find((method) => !!method?.last4)?.$id ??
|
||||
null;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
let org: Organization;
|
||||
// Create new org
|
||||
if (selectedOrgId === newOrgId) {
|
||||
org = await sdk.forConsole.billing.createOrganization(
|
||||
newOrgId,
|
||||
name,
|
||||
BillingPlan.PRO,
|
||||
paymentMethodId
|
||||
);
|
||||
}
|
||||
// Upgrade existing org
|
||||
else if (selectedOrg?.billingPlan !== BillingPlan.PRO) {
|
||||
org = await sdk.forConsole.billing.updatePlan(
|
||||
selectedOrg.$id,
|
||||
BillingPlan.PRO,
|
||||
paymentMethodId,
|
||||
null
|
||||
);
|
||||
}
|
||||
// Existing pro org
|
||||
else {
|
||||
org = selectedOrg;
|
||||
}
|
||||
|
||||
// Add coupon
|
||||
if (data.couponData?.code) {
|
||||
await sdk.forConsole.billing.addCredit(org.$id, data.couponData.code);
|
||||
}
|
||||
|
||||
// Add budget
|
||||
if (billingBudget) {
|
||||
await sdk.forConsole.billing.updateBudget(org.$id, billingBudget, [75]);
|
||||
}
|
||||
|
||||
// Add collaborators
|
||||
if (collaborators?.length) {
|
||||
collaborators.forEach(async (collaborator) => {
|
||||
await sdk.forConsole.teams.createMembership(
|
||||
org.$id,
|
||||
['owner'],
|
||||
collaborator,
|
||||
undefined,
|
||||
undefined,
|
||||
`${$page.url.origin}/console/organization-${org.$id}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Add tax ID
|
||||
if (taxId) {
|
||||
await sdk.forConsole.billing.updateTaxId(org.$id, taxId);
|
||||
}
|
||||
trackEvent(Submit.CreditRedeem, {
|
||||
coupon: data.couponData.code
|
||||
});
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
await goto(`/console/organization-${org.$id}`);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Credits applied successfully'
|
||||
});
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
} catch (e) {
|
||||
trackError(e, Submit.CreditRedeem);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: selectedOrg = $organizationList?.teams?.find(
|
||||
(team) => team.$id === selectedOrgId
|
||||
) as Organization;
|
||||
|
||||
$: isButtonDisabled = checkButtonDisabled(selectedOrgId, name, paymentMethodId);
|
||||
|
||||
function checkButtonDisabled(id: string | null, name: string | null, method: string | null) {
|
||||
if (id === newOrgId) {
|
||||
return !name || !method;
|
||||
} else if (id && selectedOrg?.billingPlan === BillingPlan.PRO) {
|
||||
return !selectedOrg?.paymentMethodId;
|
||||
} else if (id) {
|
||||
return !method;
|
||||
} else return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Apply credits - Appwrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<WizardSecondaryContainer href={previousPage} bind:showExitModal>
|
||||
<WizardSecondaryHeader confirmExit on:exit={() => (showExitModal = true)}>
|
||||
Apply credits
|
||||
</WizardSecondaryHeader>
|
||||
<WizardSecondaryContent>
|
||||
<Form bind:this={formComponent} onSubmit={handleSubmit} bind:isSubmitting>
|
||||
<FormList>
|
||||
{#if $organizationList?.total}
|
||||
<InputSelect
|
||||
bind:value={selectedOrgId}
|
||||
label="Select organization"
|
||||
{options}
|
||||
required
|
||||
placeholder="Select organization"
|
||||
id="organization" />
|
||||
{/if}
|
||||
{#if selectedOrgId && (selectedOrg?.billingPlan !== BillingPlan.PRO || !selectedOrg?.paymentMethodId)}
|
||||
{#if selectedOrgId === newOrgId}
|
||||
<InputText
|
||||
label="Organization name"
|
||||
placeholder="Enter organization name"
|
||||
id="name"
|
||||
required
|
||||
bind:value={name} />
|
||||
{/if}
|
||||
<InputTags
|
||||
bind:tags={collaborators}
|
||||
label="Invite members by email"
|
||||
tooltip="Invited members will have access to all services and payment data within your organization"
|
||||
placeholder="Enter email address(es)"
|
||||
validityRegex={emailRegex}
|
||||
validityMessage="Invalid email address"
|
||||
id="members" />
|
||||
<SelectPaymentMethod bind:methods bind:value={paymentMethodId} bind:taxId />
|
||||
{/if}
|
||||
</FormList>
|
||||
</Form>
|
||||
|
||||
<svelte:fragment slot="aside">
|
||||
<div
|
||||
class="box card-container u-position-relative"
|
||||
style:--box-border-radius="var(--border-radius-small)">
|
||||
<div class="card-bg"></div>
|
||||
<div class="u-flex u-flex-vertical u-gap-24 u-cross-center u-position-relative">
|
||||
<img
|
||||
src={`/images/campaigns/${data.couponData.campaign}/${$app.themeInUse}.png`}
|
||||
class="u-block u-image-object-fit-cover card-img"
|
||||
alt="promo" />
|
||||
<p class="text">
|
||||
{data.campaign.title.replace('VALUE', data.couponData.credits)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedOrg?.billingPlan === BillingPlan.PRO}
|
||||
<section
|
||||
class="card u-margin-block-start-24"
|
||||
style:--p-card-padding="1.5rem"
|
||||
style:--p-card-border-radius="var(--border-radius-small)">
|
||||
<p class="text">
|
||||
Credits will automatically be applied to your next invoice on <b
|
||||
>{toLocaleDate(selectedOrg.billingNextInvoiceDate)}.</b>
|
||||
</p>
|
||||
</section>
|
||||
{:else if selectedOrgId}
|
||||
<EstimatedTotalBox
|
||||
fixedCoupon
|
||||
billingPlan={BillingPlan.PRO}
|
||||
{collaborators}
|
||||
bind:couponData={data.couponData}
|
||||
bind:billingBudget />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</WizardSecondaryContent>
|
||||
|
||||
<WizardSecondaryFooter>
|
||||
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>Cancel</Button>
|
||||
<Button
|
||||
fullWidthMobile
|
||||
on:click={() => formComponent.triggerSubmit()}
|
||||
disabled={$isSubmitting || isButtonDisabled}>
|
||||
{#if $isSubmitting}
|
||||
<span class="loader is-small is-transparent u-line-height-1-5" aria-hidden="true" />
|
||||
{/if}
|
||||
Apply credits
|
||||
</Button>
|
||||
</WizardSecondaryFooter>
|
||||
<svelte:fragment slot="exit">
|
||||
You can apply your credits to an organization at a later date. All other data entered will
|
||||
be lost. Credits expire {toLocaleDate(data.couponData.expiration)}.
|
||||
</svelte:fragment>
|
||||
</WizardSecondaryContainer>
|
||||
|
||||
<style lang="scss">
|
||||
.card-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-bg {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
inset: 0;
|
||||
}
|
||||
.card-bg::before {
|
||||
position: absolute;
|
||||
inset-block-start: -30px;
|
||||
inset-inline-end: -30px;
|
||||
content: '';
|
||||
display: block;
|
||||
inline-size: 30%;
|
||||
block-size: 30%;
|
||||
background: radial-gradient(49.55% 43.54% at 47% 50.69%, #e7f8f7 0%, #85dbd8 100%);
|
||||
filter: blur(70px);
|
||||
}
|
||||
.card-bg::after {
|
||||
position: absolute;
|
||||
inset-block-end: -30px;
|
||||
inset-inline-start: -30px;
|
||||
content: '';
|
||||
display: block;
|
||||
inline-size: 30%;
|
||||
block-size: 30%;
|
||||
background: radial-gradient(50% 46.73% at 50% 53.27%, #fe9567 28.17%, #fd366e 59.38%);
|
||||
filter: blur(70px);
|
||||
}
|
||||
.card-img {
|
||||
max-width: 12.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { base } from '$app/paths';
|
||||
import type { Coupon } from '$lib/sdk/billing.js';
|
||||
import { campaigns } from '$lib/stores/campaigns.js';
|
||||
import { sdk } from '$lib/stores/sdk.js';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ url }) => {
|
||||
let couponData: Coupon;
|
||||
if (url.searchParams.has('code')) {
|
||||
const code = url.searchParams.get('code');
|
||||
try {
|
||||
couponData = await sdk.forConsole.billing.getCoupon(code);
|
||||
} catch (e) {
|
||||
redirect(303, `${base}/console`);
|
||||
}
|
||||
}
|
||||
if (!couponData?.campaign || !campaigns.has(couponData.campaign)) {
|
||||
redirect(303, `${base}/console`);
|
||||
}
|
||||
|
||||
return {
|
||||
couponData,
|
||||
campaign: campaigns.get(couponData.campaign)
|
||||
};
|
||||
};
|
||||
@@ -33,6 +33,7 @@
|
||||
);
|
||||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;
|
||||
let previousPage: string = `${base}/console`;
|
||||
let showExitModal = false;
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
previousPage = from?.url?.pathname || previousPage;
|
||||
@@ -185,8 +186,8 @@
|
||||
<title>Create organization - Appwrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<WizardSecondaryContainer>
|
||||
<WizardSecondaryHeader href={previousPage} showExitModal>
|
||||
<WizardSecondaryContainer bind:showExitModal href={previousPage}>
|
||||
<WizardSecondaryHeader confirmExit on:exit={() => (showExitModal = true)}>
|
||||
Create organization
|
||||
</WizardSecondaryHeader>
|
||||
<WizardSecondaryContent>
|
||||
@@ -194,8 +195,8 @@
|
||||
<FormList>
|
||||
<InputText
|
||||
bind:value={name}
|
||||
label="Name"
|
||||
placeholder="Enter name"
|
||||
label="Organization name"
|
||||
placeholder="Enter organization name"
|
||||
id="name"
|
||||
required />
|
||||
</FormList>
|
||||
@@ -287,7 +288,7 @@
|
||||
</WizardSecondaryContent>
|
||||
|
||||
<WizardSecondaryFooter>
|
||||
<Button fullWidthMobile href={`${base}/console`} secondary>Cancel</Button>
|
||||
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>Cancel</Button>
|
||||
<Button
|
||||
fullWidthMobile
|
||||
on:click={() => formComponent.triggerSubmit()}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
);
|
||||
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i;
|
||||
let previousPage: string = `${base}/console`;
|
||||
|
||||
let showExitModal = false;
|
||||
afterNavigate(({ from }) => {
|
||||
previousPage = from?.url?.pathname || previousPage;
|
||||
});
|
||||
@@ -74,8 +74,8 @@
|
||||
let feedbackMessage: string;
|
||||
|
||||
onMount(async () => {
|
||||
if ($page.url.searchParams.has('coupon')) {
|
||||
const coupon = $page.url.searchParams.get('coupon');
|
||||
if ($page.url.searchParams.has('code')) {
|
||||
const coupon = $page.url.searchParams.get('code');
|
||||
try {
|
||||
const response = await sdk.forConsole.billing.getCoupon(coupon);
|
||||
couponData = response;
|
||||
@@ -254,8 +254,9 @@
|
||||
<title>Change plan - Appwrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<WizardSecondaryContainer>
|
||||
<WizardSecondaryHeader href={previousPage} showExitModal>Change plan</WizardSecondaryHeader>
|
||||
<WizardSecondaryContainer bind:showExitModal href={previousPage}>
|
||||
<WizardSecondaryHeader confirmExit on:exit={() => (showExitModal = true)}
|
||||
>Change plan</WizardSecondaryHeader>
|
||||
<WizardSecondaryContent>
|
||||
<Form bind:this={formComponent} onSubmit={handleSubmit} bind:isSubmitting>
|
||||
<Label class="label u-margin-block-start-16">Select plan</Label>
|
||||
@@ -378,7 +379,7 @@
|
||||
</WizardSecondaryContent>
|
||||
|
||||
<WizardSecondaryFooter>
|
||||
<Button fullWidthMobile href={`${base}/console`} secondary>Cancel</Button>
|
||||
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>Cancel</Button>
|
||||
<Button
|
||||
fullWidthMobile
|
||||
on:click={() => formComponent.triggerSubmit()}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
let mail: string, pass: string, disabled: boolean;
|
||||
|
||||
export let data;
|
||||
|
||||
async function login() {
|
||||
try {
|
||||
disabled = true;
|
||||
@@ -39,6 +41,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
if (data?.couponData?.code) {
|
||||
trackEvent(Submit.AccountCreate, { campaign_name: data?.couponData?.code });
|
||||
await goto(`${base}/console/apply-credit?code=${data?.couponData?.code}`);
|
||||
return;
|
||||
}
|
||||
if ($page.url.searchParams) {
|
||||
const redirect = $page.url.searchParams.get('redirect');
|
||||
$page.url.searchParams.delete('redirect');
|
||||
@@ -85,7 +93,7 @@
|
||||
<title>Sign in - Appwrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<Unauthenticated>
|
||||
<Unauthenticated coupon={data?.couponData}>
|
||||
<svelte:fragment slot="title">Sign in</svelte:fragment>
|
||||
<svelte:fragment>
|
||||
<Form onSubmit={login}>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { base } from '$app/paths';
|
||||
import { campaigns } from '$lib/stores/campaigns';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ url }) => {
|
||||
if (url.searchParams.has('code')) {
|
||||
const code = url.searchParams.get('code');
|
||||
try {
|
||||
const couponData = await sdk.forConsole.billing.getCoupon(code);
|
||||
if (couponData?.campaign && campaigns.has(couponData.campaign)) {
|
||||
return {
|
||||
couponData
|
||||
};
|
||||
} else redirect(303, `${base}/login`);
|
||||
} catch (e) {
|
||||
redirect(303, `${base}/login`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
@@ -21,6 +21,8 @@
|
||||
import { page } from '$app/stores';
|
||||
import { redirectTo } from '$routes/store';
|
||||
|
||||
export let data;
|
||||
|
||||
let name: string, mail: string, pass: string, disabled: boolean;
|
||||
let terms = false;
|
||||
|
||||
@@ -36,7 +38,11 @@
|
||||
}
|
||||
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
|
||||
trackEvent(Submit.AccountCreate, { campaign_name: data?.couponData?.code });
|
||||
if (data?.couponData?.code) {
|
||||
await goto(`${base}/console/apply-credit?code=${data?.couponData?.code}`);
|
||||
return;
|
||||
}
|
||||
if ($page.url.searchParams) {
|
||||
const redirect = $page.url.searchParams.get('redirect');
|
||||
$page.url.searchParams.delete('redirect');
|
||||
@@ -73,7 +79,7 @@
|
||||
<title>Sign up - Appwrite</title>
|
||||
</svelte:head>
|
||||
|
||||
<Unauthenticated>
|
||||
<Unauthenticated coupon={data?.couponData}>
|
||||
<svelte:fragment slot="title">Sign up</svelte:fragment>
|
||||
<svelte:fragment>
|
||||
<Form onSubmit={register}>
|
||||
@@ -130,7 +136,9 @@
|
||||
<svelte:fragment slot="links">
|
||||
<li class="inline-links-item">
|
||||
<span class="text">
|
||||
Already got an account? <a class="link" href={`${base}/login`}>Sign in</a>
|
||||
Already got an account? <a
|
||||
class="link"
|
||||
href={`${base}/login${$page?.url?.search ?? ''}`}>Sign in</a>
|
||||
</span>
|
||||
</li>
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { base } from '$app/paths';
|
||||
import { campaigns } from '$lib/stores/campaigns.js';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ url }) => {
|
||||
if (url.searchParams.has('code')) {
|
||||
const code = url.searchParams.get('code');
|
||||
try {
|
||||
const couponData = await sdk.forConsole.billing.getCoupon(code);
|
||||
if (couponData?.campaign && campaigns.has(couponData.campaign)) {
|
||||
return {
|
||||
couponData
|
||||
};
|
||||
} else redirect(303, `${base}/register`);
|
||||
} catch (e) {
|
||||
redirect(303, `${base}/register`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -10,7 +10,7 @@ export function registerUserStep(page: Page): Promise<Metadata> {
|
||||
return test.step('register user', async () => {
|
||||
const seed = crypto.randomUUID();
|
||||
await page.goto('/register');
|
||||
await page.getByRole('button', { name: 'only required' }).click();
|
||||
// await page.getByRole('button', { name: 'only required' }).click();
|
||||
const inputs = {
|
||||
name: page.locator('id=name'),
|
||||
email: page.locator('id=email'),
|
||||
|
||||
Reference in New Issue
Block a user