Merge pull request #1108 from appwrite/feat-credit-flow

feat: external credit flow
This commit is contained in:
Torsten Dittmann
2024-06-05 17:42:41 +02:00
committed by GitHub
27 changed files with 676 additions and 83 deletions
@@ -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>
+2
View File
@@ -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>
+2 -2
View File
@@ -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;
+6 -2
View File
@@ -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;
+154 -13
View File
@@ -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>
+3 -3
View File
@@ -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;
+8 -21
View File
@@ -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}
+1
View File
@@ -64,6 +64,7 @@ export type Coupon = {
expiration: string;
status: string; // 'active' | 'disabled' | 'expired'
validity: number;
campaign?: string;
};
export type Credit = {
+20
View File
@@ -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.'
});
+19 -3
View File
@@ -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>
+25
View File
@@ -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()}
+9 -1
View File
@@ -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}>
+21
View File
@@ -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;
};
+11 -3
View File
@@ -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>
+21
View File
@@ -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

+1 -1
View File
@@ -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'),