Merge pull request #2610 from appwrite/patch-e2es

This commit is contained in:
Darshan
2025-12-02 18:25:01 +05:30
committed by GitHub
81 changed files with 2198 additions and 1063 deletions
+15
View File
@@ -12,16 +12,31 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: E2E Tests
run: pnpm run e2e
- uses: actions/upload-artifact@v4
+2 -2
View File
@@ -83,7 +83,7 @@ jobs:
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
@@ -162,7 +162,7 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_APPWRITE_MULTI_REGION=false"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
+8
View File
@@ -17,21 +17,29 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Audit dependencies
run: pnpm audit --audit-level high
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Svelte Diagnostics
run: pnpm run check
- name: Linter
run: pnpm run lint
- name: Unit Tests
run: pnpm run test
- name: Build Console
run: pnpm run build
+19
View File
@@ -101,4 +101,23 @@ src/
5. Before commit: `pnpm run check && pnpm run format && pnpm run lint && pnpm run test && pnpm run build`
6. **Take screenshots**: For any UI changes, capture screenshots and include them in the PR description or comments before finalizing
## Required Pre-Completion Checklist
**CRITICAL**: Before finishing any work or marking a task complete, agents MUST run the following commands in order and ensure all pass:
1. **`pnpm run format`** - Auto-fix all formatting issues
2. **`pnpm run check`** - Verify TypeScript/Svelte types (must show 0 errors, 0 warnings)
3. **`pnpm run lint`** - Check code style (ignore pre-existing issues in files you didn't modify)
4. **`pnpm run test`** - Run all unit tests (all tests must pass)
5. **`pnpm run build`** - Ensure production build succeeds
If any command fails:
- **Format/Lint**: Run `pnpm run format` to auto-fix, then re-check
- **Type errors**: Fix all TypeScript errors in files you modified
- **Test failures**: Fix failing tests or ensure failures are unrelated to your changes
- **Build failures**: Debug and resolve build issues before proceeding
**Never skip these checks** - they are mandatory quality gates before any work is considered complete.
**Trust these instructions** - only search if incomplete/incorrect. See CONTRIBUTING.md for PR conventions. Use `--frozen-lockfile` always. Docker builds: multi-stage, final image is nginx serving static files from `/console` path.
+4 -4
View File
@@ -12,7 +12,7 @@
"clean": "rm -rf node_modules && rm -rf .svelte_kit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --cache --write --cache .",
"format": "prettier --write --cache .",
"lint": "prettier --check . && eslint .",
"test": "TZ=EST vitest run",
"test:ui": "TZ=EST vitest --ui",
@@ -22,11 +22,11 @@
},
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@5ce6dc7",
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@406d8be",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@5b26bb8",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@865e2fc",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@33845eb",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@865e2fc",
"@faker-js/faker": "^9.9.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^10.25.0",
+5 -4
View File
@@ -1,7 +1,7 @@
import { type PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
timeout: 120000,
timeout: 240000,
reportSlowTests: null,
reporter: [['html', { open: 'never' }]],
retries: 3,
@@ -11,10 +11,11 @@ const config: PlaywrightTestConfig = {
trace: 'on-first-retry'
},
webServer: {
timeout: 120000,
timeout: 240000,
env: {
PUBLIC_CONSOLE_PROFILE: '',
PUBLIC_AI_SERVICE_BASE_URL: '',
NODE_OPTIONS: '--max_old_space_size=8192',
PUBLIC_CONSOLE_PROFILE: 'console',
PUBLIC_AI_SERVICE_BASE_URL: 'http://appwrite.test/v1',
PUBLIC_APPWRITE_ENDPOINT: 'https://stage.cloud.appwrite.io/v1',
PUBLIC_CONSOLE_MODE: 'cloud',
PUBLIC_APPWRITE_MULTI_REGION: 'true',
+281 -274
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -195,7 +195,10 @@ export enum Click {
VariablesCreateClick = 'click_variable_create',
VariablesUpdateClick = 'click_variable_update',
VariablesImportClick = 'click_variable_import',
WebsiteOpenClick = 'click_open_website'
WebsiteOpenClick = 'click_open_website',
CopyPromptStarterKitClick = 'click_copy_prompt_starter_kit',
OpenInCursorClick = 'click_open_in_cursor',
OpenInLovableClick = 'click_open_in_lovable'
}
export enum Submit {
@@ -357,6 +360,7 @@ export enum Submit {
BucketUpdateSize = 'submit_bucket_update_size',
BucketUpdateCompression = 'submit_bucket_update_compression',
BucketUpdateExtensions = 'submit_bucket_update_extensions',
BucketUpdateTransformations = 'submit_bucket_update_transformations',
FileCreate = 'submit_file_create',
FileDelete = 'submit_file_delete',
FileUpdatePermissions = 'submit_file_update_permissions',
+8 -12
View File
@@ -32,12 +32,17 @@
<script lang="ts">
import { dev } from '$app/environment';
import { fade } from 'svelte/transition';
import { last } from '$lib/helpers/array';
import { fade } from 'svelte/transition';
import { portal } from '$lib/actions/portal';
import { debounce } from '$lib/helpers/debounce';
import {
commandCenterKeyDownHandler,
disableCommands,
isTargetInputLike,
registerCommands
} from './commands';
import { addNotification } from '$lib/stores/notifications';
import { commandCenterKeyDownHandler, disableCommands, registerCommands } from './commands';
let debugOverlayEnabled = false;
@@ -99,20 +104,11 @@
keys = [];
}, 1000);
function isInputEvent(event: KeyboardEvent) {
if (
event.target instanceof HTMLElement &&
customElements.get(event.target.tagName.toLowerCase())
)
return true;
return ['INPUT', 'TEXTAREA', 'SELECT'].includes((event.target as HTMLElement).tagName);
}
const handleKeydown = (e: KeyboardEvent) => {
// check if in any webcomponent
if (!$subPanels.length) {
if (isInputEvent(e)) return;
if (isTargetInputLike(e.target)) return;
keys = [...keys, e.key].slice(-10);
resetKeys();
}
+12 -7
View File
@@ -100,13 +100,13 @@ const commandsEnabled = derived(disabledMap, ($disabledMap) => {
return Array.from($disabledMap.values()).every((disabled) => !disabled);
});
function isInputEvent(event: KeyboardEvent) {
if (
event.target instanceof HTMLElement &&
customElements.get(event.target.tagName.toLowerCase())
)
export function isTargetInputLike(element: EventTarget | null) {
if (!(element instanceof HTMLElement)) return false;
if (element instanceof HTMLElement && customElements.get(element.tagName.toLowerCase()))
return true;
return ['INPUT', 'TEXTAREA', 'SELECT'].includes((event.target as HTMLElement).tagName);
return !!element.closest(
'input,textarea,select,[contenteditable],[role="combobox"],[role="textbox"],[role="searchbox"],[data-command-center-ignore]'
);
}
function getCommandRank(command: KeyedCommand) {
@@ -209,7 +209,12 @@ export const commandCenterKeyDownHandler = derived(
for (const command of commandsArr) {
if (!isKeyedCommand(command)) continue;
if (!command.forceEnable) {
if (command.disabled || !enabled || isInputEvent(event) || $wizard.show) {
if (
command.disabled ||
!enabled ||
isTargetInputLike(event.target) ||
$wizard.show
) {
continue;
}
}
+15 -41
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import { Button, InputText } from '$lib/elements/forms';
import { DropList, GridItem1, CardContainer, Modal } from '$lib/components';
import { GridItem1, CardContainer, Modal } from '$lib/components';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import {
Badge,
Icon,
Typography,
Tag,
Accordion,
ActionMenu,
Popover,
@@ -20,7 +19,6 @@
IconFlutter,
IconReact,
IconUnity,
IconInfo,
IconDotsHorizontal,
IconInboxIn,
IconSwitchHorizontal,
@@ -41,6 +39,7 @@
import { regions as regionsStore } from '$lib/stores/organization';
import type { Organization } from '$lib/stores/organization';
import type { Plan } from '$lib/sdk/billing';
import { formatName as formatNameHelper } from '$lib/helpers/string';
// props
interface Props {
@@ -51,8 +50,9 @@
let { projectsToArchive, organization, currentPlan }: Props = $props();
// Track Read-only info droplist per archived project
let readOnlyInfoOpen = $state<Record<string, boolean>>({});
// Check if current plan order is less than Pro (order < 1 means FREE plan)
let isPlanBelowPro = $derived(currentPlan?.order < 1);
let showUnarchiveModal = $state(false);
let projectToUnarchive = $state<Models.Project | null>(null);
let showDeleteModal = $state(false);
@@ -96,7 +96,7 @@
function isUnarchiveDisabled(): boolean {
if (!organization || !currentPlan) return true;
if (isFreePlan(organization.billingPlan)) {
if (isPlanBelowPro) {
const currentProjectCount = organization.projects?.length || 0;
const projectLimit = currentPlan.projects || 0;
@@ -187,8 +187,6 @@
return $regionsStore.regions.find((region) => region.$id === project.region);
}
import { formatName as formatNameHelper } from '$lib/helpers/string';
import { isFreePlan } from '$lib/helpers/billing';
function formatName(name: string, limit: number = 19) {
return formatNameHelper(name, limit, $isSmallViewport);
}
@@ -196,10 +194,15 @@
{#if projectsToArchive.length > 0}
<div class="archive-projects-margin-top">
<Accordion title="Archived projects" badge={`${projectsToArchive.length}`}>
<Accordion
title={isPlanBelowPro ? 'Archived projects' : 'Pending archive'}
badge={`${projectsToArchive.length}`}>
<Typography.Text tag="p" size="s">
These projects have been archived and are read-only. You can view and migrate their
data.
{#if isPlanBelowPro}
These projects are archived and require a plan upgrade to restore access.
{:else}
These projects will be archived at the end of your billing cycle.
{/if}
</Typography.Text>
<div class="archive-projects-margin">
@@ -216,36 +219,6 @@
<svelte:fragment slot="title">{formatted}</svelte:fragment>
<svelte:fragment slot="status">
<div class="status-container">
<DropList
bind:show={readOnlyInfoOpen[project.$id]}
placement="bottom-start"
noArrow>
<Tag
size="s"
style="white-space: nowrap;"
on:click={(e) => {
e.preventDefault();
e.stopPropagation();
readOnlyInfoOpen = {
...readOnlyInfoOpen,
[project.$id]: !readOnlyInfoOpen[project.$id]
};
}}>
<Icon icon={IconInfo} size="s" />
<span>Read only</span>
</Tag>
<svelte:fragment slot="list">
<li
class="drop-list-item u-width-250"
style="padding: var(--space-5, 12px) var(--space-6, 16px)">
<span class="u-block u-mb-8">
Archived projects are read-only. You can view
and migrate their data, but they no longer
accept edits or requests.
</span>
</li>
</svelte:fragment>
</DropList>
<Popover let:toggle padding="none" placement="bottom-end">
<Button
text
@@ -267,6 +240,7 @@
>Unarchive project</ActionMenu.Item.Button>
<ActionMenu.Item.Button
leadingIcon={IconSwitchHorizontal}
disabled={isUnarchiveDisabled()}
on:click={() => handleMigrateProject(project)}
>Migrate project</ActionMenu.Item.Button>
<div class="action-menu-divider">
+22 -1
View File
@@ -4,7 +4,7 @@
<script lang="ts">
import { createPopper, type Instance } from '@popperjs/core';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { createEventDispatcher, onDestroy, onMount, hasContext } from 'svelte';
export let show = false;
export let noArrow = false;
@@ -17,6 +17,10 @@
export let display = 'block';
export let arrowSize = 10;
export let isPopover = false;
export let portal = false;
const inModal = hasContext('dialog-group');
const shouldPortalTooltip = portal || inModal;
const dispatch = createEventDispatcher<{
blur: undefined;
@@ -27,6 +31,22 @@
let arrow: HTMLDivElement;
let instance: Instance;
function portalAction(node: HTMLElement) {
if (!shouldPortalTooltip) return;
const bodyElement = document.body;
const target = inModal ? node.closest('dialog[open]') || bodyElement : bodyElement;
target.appendChild(node);
return {
destroy() {
if (node.parentElement === target) {
target.removeChild(node);
}
}
};
}
onMount(() => {
instance = createPopper(element, tooltip, {
placement,
@@ -116,6 +136,7 @@
</div>
<div
use:portalAction
class="drop-tooltip"
bind:this={tooltip}
class:u-width-full-line={fullWidth}
+2
View File
@@ -18,6 +18,7 @@
export let paddingInline: string = undefined;
export let resetListPadding: boolean = false;
export let gap: string = undefined;
export let portal = false;
let classes: string = '';
export { classes as class };
</script>
@@ -31,6 +32,7 @@
{fullWidth}
{wrapperFullWidth}
{fixed}
{portal}
on:blur>
<slot />
<svelte:fragment slot="list">
+10 -3
View File
@@ -1,13 +1,20 @@
<script>
import { getNextTier, tierToPlan } from '$lib/stores/billing';
<script lang="ts">
import { isSmallViewport } from '$lib/stores/viewport';
import { organization } from '$lib/stores/organization';
import { getNextTier, tierToPlan } from '$lib/stores/billing';
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
export let source = 'empty_state_card';
export let responsive = false;
// type def for Layout.Stack!
let direction: 'column' | 'row' | 'row-reverse' | 'column-reverse' = 'row';
$: direction = responsive ? ($isSmallViewport ? 'column' : 'row') : 'row';
</script>
<Card.Base variant="secondary" padding="s" radius="s">
<Layout.Stack direction="row" gap="l">
<Layout.Stack {direction} gap="l">
{#if $$slots?.image}
<div style="flex-shrink:0">
<slot name="image" />
+14 -5
View File
@@ -6,8 +6,8 @@
</script>
<script lang="ts">
import { InputDateTime, InputSelect } from '$lib/elements/forms';
import { isSameDay, isValidDate, toLocaleDate } from '$lib/helpers/date';
import { InputDate, InputSelect } from '$lib/elements/forms';
import { isSameDay, isValidDate, toLocaleDate, toLocaleDateISO } from '$lib/helpers/date';
function incrementToday(value: number, type: 'day' | 'month' | 'year'): string {
const date = new Date();
@@ -74,6 +74,10 @@
export let resourceType: string | 'key' | 'token' | undefined = 'key';
export let expiryOptions: 'default' | 'limited' | ExpirationOptions[] = 'default';
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
let minDate: string = toLocaleDateISO(tomorrow.getTime());
const options = Array.isArray(expiryOptions)
? expiryOptions
: expiryOptions === 'default'
@@ -129,6 +133,11 @@
if (hasUserInteracted && !isSameDay(new Date(expirationSelect), new Date(value))) {
value = expirationSelect === 'custom' ? expirationCustom : expirationSelect;
}
// Only convert to ISO date if value is not null
if (value !== null) {
value = toLocaleDateISO(new Date(value).getTime());
}
}
$: helper =
@@ -147,11 +156,11 @@
on:change={() => (hasUserInteracted = true)} />
{#if expirationSelect === 'custom'}
<InputDateTime
<InputDate
required
type="date"
id="expire"
min={minDate}
label={dateSelectorLabel}
bind:value={expirationCustom}
on:change={() => (hasUserInteracted = true)} />
on:input={() => (hasUserInteracted = true)} />
{/if}
@@ -112,7 +112,6 @@
repository.set(e);
repositoryName = e.name;
selectedRepository = e.id;
connectRepo();
}} />
{/if}
</Layout.Stack>
@@ -140,7 +140,7 @@
{#each [...$groups] as [role, permission] (role)}
<Table.Row.Base {root}>
<Table.Cell column="role" {root}>
<Row {role} onNotFound={() => deleteRole(role)} />
<Row {role} />
</Table.Cell>
<Table.Cell column="create" {root}>
<Selector.Checkbox
+43 -3
View File
@@ -1,6 +1,7 @@
export const PAGE_LIMIT = 12; // default page limit
export const SPREADSHEET_PAGE_LIMIT = 50; // default sheet page limit
export const CARD_LIMIT = 6; // default card limit
export const DEFAULT_BILLING_PROJECTS_LIMIT = 5; // default billing projects page limit
export const INTERVAL = 5 * 60000; // default interval to check for feedback
export const NEW_DEV_PRO_UPGRADE_COUPON = 'appw50';
@@ -24,6 +25,7 @@ export enum Dependencies {
CREDIT = 'dependency:credit',
INVOICES = 'dependency:invoices',
ADDRESS = 'dependency:address',
BILLING_AGGREGATION = 'dependency:billing_aggregation',
UPGRADE_PLAN = 'dependency:upgrade_plan',
ORGANIZATIONS = 'dependency:organizations',
PAYMENT_METHODS = 'dependency:paymentMethods',
@@ -253,14 +255,13 @@ export const scopes: {
},
{
scope: 'indexes.read',
description: "Access to read your project's database collection's indexes",
description: "Access to read your project's database table's indexes",
category: 'Database',
icon: 'database'
},
{
scope: 'indexes.write',
description:
"Access to create, update, and delete your project's database collection's indexes",
description: "Access to create, update, and delete your project's database table's indexes",
category: 'Database',
icon: 'database'
},
@@ -466,6 +467,45 @@ export const scopes: {
}
];
export const cloudOnlyBackupScopes = [
{
scope: 'policies.read',
description: 'Access to read your database backup policies',
category: 'Database',
icon: 'database'
},
{
scope: 'policies.write',
description: 'Access to create, update and delete your backup policies',
category: 'Database',
icon: 'database'
},
{
scope: 'archives.read',
description: 'Access to read your database backup archives',
category: 'Database',
icon: 'database'
},
{
scope: 'archives.write',
description: 'Access to create and delete your backup archives',
category: 'Database',
icon: 'database'
},
{
scope: 'restorations.read',
description: 'Access to read your backup restorations',
category: 'Database',
icon: 'database'
},
{
scope: 'restorations.write',
description: 'Access to create backup restorations',
category: 'Database',
icon: 'database'
}
];
export type EventService = {
name: string;
resources: EventResource[];
@@ -56,6 +56,7 @@
helper={error ?? helper}
{required}
state={error ? 'error' : 'default'}
data-command-center-ignore
on:invalid={handleInvalid}
on:input
on:change
+1 -1
View File
@@ -2,7 +2,7 @@ import { BillingPlan } from '@appwrite.io/console';
export function isFreePlan(plan: BillingPlan | string): boolean {
switch (plan) {
case BillingPlan.Imaginebasic:
case BillingPlan.Imaginetier0:
case BillingPlan.Tier0:
return true;
default:
+45 -4
View File
@@ -1,8 +1,15 @@
import type { Models } from '@appwrite.io/console';
/**
* Build VCS repo URL from the template response model.
* Example (GitHub): https://github.com/appwrite/templates-for-sites
* build VCS repo URL from the template response model.
* supports GitHub, GitLab, and Bitbucket.
*
* important: We use 'master' as the branch name because GitHub (and other providers)
* redirect 'master' to the repository's default branch, regardless of whether
* its actually named 'main', 'master', or something else. This ensures the
* link works across all repositories without needing to know their default branch.
*
* Example (GitHub): https://github.com/appwrite/templates-for-sites/tree/master/sveltekit/starter
*/
export function getTemplateSourceUrl(
t: Models.TemplateSite | Models.TemplateFunction
@@ -20,7 +27,41 @@ export function getTemplateSourceUrl(
bitbucket: 'bitbucket.org'
};
const host = hostMap[provider.toLowerCase()] ?? provider; // fallback
const host = hostMap[provider.toLowerCase()];
if (!host) return null;
return `https://${host}/${owner}/${repo}`;
let folderPath: string | undefined;
if (
'providerRootDirectory' in t &&
t.providerRootDirectory &&
typeof t.providerRootDirectory === 'string'
) {
folderPath = t.providerRootDirectory;
} else if (
'frameworks' in t &&
t.frameworks?.length > 0 &&
t.frameworks[0]?.providerRootDirectory &&
typeof t.frameworks[0].providerRootDirectory === 'string'
) {
folderPath = t.frameworks[0].providerRootDirectory;
}
let url = `https://${host}/${owner}/${repo}`;
if (folderPath) {
const normalizedPath = folderPath.replace(/^\/+|\/+$/g, '');
if (normalizedPath) {
const providerLower = provider.toLowerCase();
// Use 'master' as branch name - GitHub/GitLab/Bitbucket redirect it to default branch
if (providerLower === 'github') {
url = `${url}/tree/master/${normalizedPath}`;
} else if (providerLower === 'gitlab') {
url = `${url}/-/tree/master/${normalizedPath}`;
} else if (providerLower === 'bitbucket') {
url = `${url}/src/master/${normalizedPath}`;
}
}
}
return url;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 834 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

+1 -1
View File
@@ -113,7 +113,7 @@ export const studio: Profile = {
id: ProfileMode.STUDIO,
platform: 'Imagine',
organizationPlatform: Platform.Imagine,
freeTier: BillingPlan.Imaginebasic,
freeTier: BillingPlan.Imaginetier0,
logo: {
src: {
dark: asset('/images/imagine-logo-dark.svg'),
+16 -3
View File
@@ -491,7 +491,7 @@ export class Billing {
billingPlan: string,
paymentMethodId: string,
platform: Platform,
billingAddressId: string = null,
billingAddressId: string = undefined,
couponId: string = null,
invites: Array<string> = [],
budget: number = undefined,
@@ -630,6 +630,7 @@ export class Billing {
budget,
taxId
};
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'patch',
@@ -936,12 +937,24 @@ export class Billing {
);
}
async getAggregation(organizationId: string, aggregationId: string): Promise<AggregationTeam> {
async getAggregation(
organizationId: string,
aggregationId: string,
limit?: number,
offset?: number
): Promise<AggregationTeam> {
const path = `/organizations/${organizationId}/aggregations/${aggregationId}`;
const params = {
const params: {
organizationId: string;
aggregationId: string;
limit?: number;
offset?: number;
} = {
organizationId,
aggregationId
};
if (typeof limit === 'number') params.limit = limit;
if (typeof offset === 'number') params.offset = offset;
const uri = new URL(this.client.config.endpoint + path);
return await this.client.call(
'get',
+2 -2
View File
@@ -40,8 +40,8 @@ import { user } from './user';
import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
import ProjectsLimit from '$lib/components/billing/alerts/projectsLimit.svelte';
import { ProfileMode, resolvedProfile } from '$lib/profiles/index.svelte';
import { isFreePlan } from '$lib/helpers/billing';
import { ProfileMode, resolvedProfile } from '$lib/profiles/index.svelte';
export type Tier = 'tier-0' | 'tier-1' | 'tier-2' | 'auto-1' | 'cont-1' | 'ent-1';
@@ -101,7 +101,7 @@ export function tierToPlan(tier: Tier) {
case BillingPlan.ENTERPRISE:
return tierEnterprise;
default:
return tierFree;
return tierCustom;
}
}
+2 -2
View File
@@ -30,12 +30,12 @@ export type FeedbackOption = {
export const feedbackOptions: FeedbackOption[] = [
{
type: 'general',
desc: `Imagine evolves with your input. Share your thoughts and help us improve Imagine.`, // @todo fix
desc: `${resolvedProfile.platform} evolves with your input. Share your thoughts and help us improve ${resolvedProfile.platform}.`,
component: FeedbackGeneral
},
{
type: 'nps',
desc: `How likely are you to recommend Imagine to a friend or colleague?`, // @todo fix
desc: `How likely are you to recommend ${resolvedProfile.platform} to a friend or colleague?`,
component: FeedbackNps
}
];
+22 -3
View File
@@ -102,13 +102,32 @@ export async function submitStripeCard(name: string, organizationId?: string) {
}
if (setupIntent && setupIntent.status === 'succeeded') {
if ((setupIntent.payment_method as PaymentMethod).card?.country === 'US') {
const pm = setupIntent.payment_method as PaymentMethod | string | undefined;
// If Stripe returned an expanded PaymentMethod object, check the card country.
// If it returned a string id (common), `typeof pm === 'string'` and we skip this.
if (typeof pm !== 'string' && pm?.card?.country === 'US') {
// need to get state
return setupIntent.payment_method as PaymentMethod;
return pm as PaymentMethod;
}
// The backend expects a provider method ID (string). Extract the id
// whether Stripe returned the id string or an expanded object.
let providerId: string | undefined;
if (typeof pm === 'string') {
providerId = pm;
} else {
providerId = (pm as PaymentMethod)?.id;
}
if (!providerId) {
const e = new Error('Unable to verify payment method.');
trackError(e, Submit.PaymentMethodCreate);
throw e;
}
const method = await sdk.forConsole.billing.setPaymentMethod(
paymentMethod.$id,
(setupIntent.payment_method as PaymentMethod).id,
providerId,
name
);
paymentElement.destroy();
+45 -4
View File
@@ -1,10 +1,51 @@
<script>
import { Container } from '$lib/layout';
import { base } from '$app/paths';
import { loading } from '$routes/store';
import { app } from '$lib/stores/app';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
loading.set(false);
</script>
<Container>
<slot />
</Container>
<Layout.Stack
height="100vh"
direction="column"
alignItems="center"
justifyContent="center"
style="background: var(--bgcolor-neutral-primary, #fff);">
<section class="console-container">
<slot />
</section>
<footer>
<Typography.Eyebrow color="--fgcolor-neutral-secondary">POWERED BY</Typography.Eyebrow>
{#if $app.themeInUse === 'dark'}
<img
src="{base}/images/appwrite-logo-dark.svg"
width="120"
height="22"
alt="Appwrite Logo" />
{:else}
<img
src="{base}/images/appwrite-logo-light.svg"
width="120"
height="22"
alt="Appwrite Logo" />
{/if}
</footer>
</Layout.Stack>
<style lang="scss">
section {
flex: 1;
display: flex;
align-items: center;
}
footer {
padding: 2rem 1rem;
display: flex;
gap: 0.5rem;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
</style>
@@ -1,31 +1,21 @@
<script lang="ts">
import { app } from '$lib/stores/app';
import AppwriteLogoDark from '$lib/images/appwrite-logo-dark.svg';
import AppwriteLogoLight from '$lib/images/appwrite-logo-light.svg';
import { Vcs, Client } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { getApiEndpoint } from '$lib/stores/sdk';
import { resolvedProfile } from '$lib/profiles/index.svelte';
import { Badge, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Button } from '$lib/elements/forms';
export let data;
const { data } = $props();
const endpoint = getApiEndpoint();
const client = new Client();
const vcs = new Vcs(client);
let installationId: string;
let repositoryId: string;
let providerPullRequestId: string;
let error = '';
let success = '';
let loading = false;
let error = $state('');
let success = $state('');
let loading = $state(false);
onMount(async () => {
repositoryId = data.repositoryId;
installationId = data.installationId;
providerPullRequestId = data.providerPullRequestId;
client.setEndpoint(endpoint).setProject(data.projectId).setMode('admin');
});
@@ -40,9 +30,9 @@
try {
await vcs.updateExternalDeployments({
installationId,
repositoryId,
providerPullRequestId
installationId: data.installationId,
repositoryId: data.repositoryId,
providerPullRequestId: data.providerPullRequestId
});
success = 'Deployment approved successfully! Build will start soon.';
} catch (e) {
@@ -53,49 +43,15 @@
}
</script>
<section class="container" style="display: grid; place-items: center; min-height: 100vh;">
<div class="u-flex u-flex-vertical u-cross-center" style="width: 100%">
<div class="card" style="min-width: 600px; max-width: 100%;">
<h1 class="heading-level-2">Authorize External Deployment</h1>
<small style="margin-block-start: 8px;display: block;"
>The deployment for pull request <code class="inline-code"
>#{providerPullRequestId}</code> is awaiting approval. When authorized, deployments
will be started.
</small>
<div class="with-borders" style="margin-block-start: 1rem;display: block;">
<button disabled={loading} on:click={approveDeployment} class="button" type="button"
>Approve Deployment</button>
</div>
{#if error}
<p style="margin-block-start: 1rem" class="u-color-text-danger">{error}</p>
{/if}
{#if success}
<p style="margin-block-start: 1rem" class="u-color-text-success">{success}</p>
{/if}
</div>
<div class="u-gap-4 u-flex u-main-center u-cross-center" style="margin-block-start: 2rem;">
<span class="text">Powered by</span>
<a
href="https://appwrite.io/"
target="_blank"
style="display: grid;place-items: center;">
{#if $app.themeInUse === 'dark'}
<img
src={AppwriteLogoDark}
width="120"
class="u-block u-only-dark"
alt="{resolvedProfile.platform} Logo" />
{:else}
<img
src={AppwriteLogoLight}
width="120"
class="u-block u-only-light"
alt="{resolvedProfile.platform} Logo" />
{/if}
</a>
</div>
</div>
</section>
<Layout.Stack gap="l" alignItems="center" style="max-width: 500px;">
{#if success}
<Badge type="success" variant="secondary" content={success} />
{:else if error}
<Badge type="error" variant="secondary" content={error} />
{/if}
<Typography.Title size="l" align="center">
The deployment for pull request #{data.providerPullRequestId}
is awaiting approval. When authorized, deployments will be started.
</Typography.Title>
<Button on:click={approveDeployment} secondary>Approve Deployment</Button>
</Layout.Stack>
@@ -13,10 +13,10 @@
import { sdk } from '$lib/stores/sdk';
import type { PageData } from './$types';
import { isCloud } from '$lib/system';
import { Badge } from '@appwrite.io/pink-svelte';
import { Badge, Skeleton } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
import type { Organization } from '$lib/stores/organization';
import { daysLeftInTrial, plansInfo, tierToPlan } from '$lib/stores/billing';
import { daysLeftInTrial, plansInfo, tierToPlan, type Tier } from '$lib/stores/billing';
import { toLocaleDate } from '$lib/helpers/date';
import { BillingPlan } from '$lib/constants';
import { goto } from '$app/navigation';
@@ -37,6 +37,27 @@
return memberships.memberships.map((team) => team.userName || team.userEmail);
}
async function getPlanName(billingPlan: string | undefined): Promise<string> {
if (!billingPlan) return 'Unknown';
// For known plans, use tierToPlan
const tierData = tierToPlan(billingPlan as Tier);
// If it's not a custom plan or we got a non-custom result, return the name
if (tierData.name !== 'Custom') {
return tierData.name;
}
// For custom plans, fetch from API
try {
const plan = await sdk.forConsole.billing.getPlan(billingPlan);
return plan.name;
} catch (error) {
// Fallback to 'Custom' if fetch fails
return 'Custom';
}
}
function isOrganizationOnTrial(organization: Organization): boolean {
if (!organization?.billingTrialStartDate) return false;
if ($daysLeftInTrial <= 0) return false;
@@ -93,6 +114,9 @@
{#each data.organizations.teams as organization}
{@const avatarList = getMemberships(organization.$id)}
{@const payingOrg = isPayingOrganization(organization)}
{@const planName = isCloudOrg(organization)
? getPlanName(organization.billingPlan)
: null}
<GridItem1 href={`${base}/organization-${organization.$id}`}>
<svelte:fragment slot="eyebrow">
@@ -105,16 +129,19 @@
<svelte:fragment slot="status">
{#if isCloudOrg(organization)}
{#if isNonPayingOrganization(organization)}
<Tooltip>
<Badge
size="xs"
variant="secondary"
content={tierToPlan(organization?.billingPlan)?.name} />
{#if planName}
{#await planName}
<Skeleton width={30} height={20} variant="line" />
{:then name}
<Tooltip>
<Badge size="xs" variant="secondary" content={name} />
<span slot="tooltip">
You are limited to 1 free organization per account
</span>
</Tooltip>
<span slot="tooltip">
You are limited to 1 free organization per account
</span>
</Tooltip>
{/await}
{/if}
{/if}
{#if isOrganizationOnTrial(organization)}
@@ -133,16 +160,20 @@
{/if}
{#if payingOrg}
<Badge
size="xs"
type="success"
variant="secondary"
content={tierToPlan(payingOrg?.billingPlan)?.name} />
{#await planName}
<Skeleton width={30} height={20} variant="line" />
{:then name}
<Badge
size="xs"
type="success"
variant="secondary"
content={name} />
{/await}
{/if}
{/if}
</svelte:fragment>
{#await avatarList}
<span class="avatar is-color-empty"></span>
<Skeleton width={40} height={40} variant="circle" />
{:then avatars}
<AvatarGroup {avatars} />
{/await}
@@ -136,7 +136,7 @@
billingPlan,
paymentMethodId,
resolvedProfile.organizationPlatform,
null,
undefined,
couponData.code ? couponData.code : null,
collaborators,
billingBudget,
@@ -150,7 +150,7 @@
selectedOrg.$id,
billingPlan,
paymentMethodId,
null,
undefined,
couponData.code ? couponData.code : null,
collaborators
);
+10 -10
View File
@@ -1,33 +1,33 @@
import { isCloud } from '$lib/system';
import { isSameDay } from '$lib/helpers/date';
import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts';
import DbOperatorsDark from '$lib/images/promos/db-operators-dark.png';
import DbOperatorsLight from '$lib/images/promos/db-operators-light.png';
import AiSuggestionsDark from '$lib/images/promos/ai-suggestions-dark.png';
import AiSuggestionsLight from '$lib/images/promos/ai-suggestions-light.png';
import { ProfileMode, resolvedProfile } from '$lib/profiles/index.svelte';
const listOfPromotions: BottomModalAlertItem[] = [];
if (isCloud) {
const dbOperatorsPromo: BottomModalAlertItem = {
id: 'modal:db_operators_announcement',
const aiSuggestionsPromo: BottomModalAlertItem = {
id: 'modal:database_ai_suggestions_announcement',
src: {
dark: DbOperatorsDark,
light: DbOperatorsLight
dark: AiSuggestionsDark,
light: AiSuggestionsLight
},
title: 'Announcing DB operators',
message: 'Update multiple fields without fetching the entire row.',
title: 'Announcing Database AI suggestions',
message: 'From table name to schema in one click.',
plan: 'free',
importance: 8,
scope: 'project',
cta: {
text: 'Read announcement',
link: () => 'https://appwrite.io/blog/post/announcing-db-operators',
link: () => 'https://appwrite.io/blog/post/announcing-database-ai-suggestions',
external: true,
hideOnClick: true
},
show: true
};
listOfPromotions.push(dbOperatorsPromo);
listOfPromotions.push(aiSuggestionsPromo);
}
export function addBottomModalAlerts() {
@@ -115,8 +115,7 @@
name,
resolvedProfile.freeTier,
null,
resolvedProfile.organizationPlatform,
null
resolvedProfile.organizationPlatform
);
} else {
org = await sdk.forConsole.billing.createOrganization(
@@ -125,7 +124,7 @@
selectedPlan,
paymentMethodId,
resolvedProfile.organizationPlatform,
null,
undefined,
selectedCoupon?.code,
collaborators,
billingBudget,
@@ -27,8 +27,7 @@
organizationName,
resolvedProfile.freeTier,
null,
resolvedProfile.organizationPlatform,
null
resolvedProfile.organizationPlatform
);
trackEvent(Submit.OrganizationCreate, {
@@ -46,8 +46,7 @@ export const load: PageLoad = async ({ parent }) => {
'Personal projects',
resolvedProfile.freeTier,
null,
resolvedProfile.organizationPlatform,
null
resolvedProfile.organizationPlatform
);
trackEvent(Submit.OrganizationCreate, {
plan: tierToPlan(BillingPlan.FREE)?.name,
@@ -135,7 +135,9 @@
availableCredit={data?.availableCredit}
currentPlan={data?.currentPlan}
nextPlan={data?.nextPlan}
currentAggregation={data?.billingAggregation} />
currentAggregation={data?.billingAggregation}
limit={data?.limit}
offset={data?.offset} />
{:else}
<PlanSummaryOld
availableCredit={data?.availableCredit}
@@ -1,4 +1,4 @@
import { BillingPlan, Dependencies } from '$lib/constants';
import { BillingPlan, DEFAULT_BILLING_PROJECTS_LIMIT, Dependencies } from '$lib/constants';
import type { Address } from '$lib/sdk/billing';
import { type Organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
@@ -8,7 +8,9 @@ import { isCloud } from '$lib/system';
import { base } from '$app/paths';
import { isFreePlan } from '$lib/helpers/billing';
export const load: PageLoad = async ({ parent, depends }) => {
import { getLimit, getPage, pageToOffset } from '$lib/helpers/load';
export const load: PageLoad = async ({ parent, depends, url, route }) => {
const { organization, scopes, currentPlan, countryList, locale } = await parent();
if (!scopes.includes('billing.read')) {
@@ -20,6 +22,8 @@ export const load: PageLoad = async ({ parent, depends }) => {
depends(Dependencies.CREDIT);
depends(Dependencies.INVOICES);
depends(Dependencies.ADDRESS);
//aggregation reloads on page param changes
depends(Dependencies.BILLING_AGGREGATION);
const billingAddressId = (organization as Organization)?.billingAddressId;
const billingAddressPromise: Promise<Address> = billingAddressId
@@ -35,9 +39,14 @@ export const load: PageLoad = async ({ parent, depends }) => {
*/
let billingAggregation = null;
try {
const currentPage = getPage(url) || 1;
const limit = getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT);
const offset = pageToOffset(currentPage, limit);
billingAggregation = await sdk.forConsole.billing.getAggregation(
organization.$id,
(organization as Organization)?.billingAggregationId
(organization as Organization)?.billingAggregationId,
limit,
offset
);
} catch (e) {
// ignore error
@@ -85,6 +94,11 @@ export const load: PageLoad = async ({ parent, depends }) => {
areCreditsSupported,
countryList,
locale,
nextPlan: billingPlanDowngrade
nextPlan: billingPlanDowngrade,
limit: getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT),
offset: pageToOffset(
getPage(url) || 1,
getLimit(url, route, DEFAULT_BILLING_PROJECTS_LIMIT)
)
};
};
@@ -152,7 +152,7 @@
bind:selectedAddress={billingAddress} />
{/if}
{#if showReplace}
<ReplaceAddress bind:show={showReplace} />
<ReplaceAddress bind:show={showReplace} {locale} {countryList} />
{/if}
{#if showRemove}
<RemoveAddress bind:show={showRemove} />
File diff suppressed because it is too large Load Diff
@@ -200,7 +200,7 @@
disabled={$organization?.markedForDeletion}
href={$upgradeURL}
on:click={() =>
trackEvent('click_organization_plan_update', {
trackEvent(Click.OrganizationClickUpgrade, {
from: 'button',
source: 'billing_tab'
})}>
@@ -11,9 +11,11 @@
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { base } from '$app/paths';
import { Alert, Badge, Card, Layout, Skeleton } from '@appwrite.io/pink-svelte';
import { page } from '$app/state';
import type { Models } from '@appwrite.io/console';
export let show = false;
export let locale: Models.Locale;
export let countryList: Models.CountryList;
let loading = true;
let addresses: AddressesList;
let selectedAddress: string;
@@ -44,13 +46,9 @@
: null
: null;
const locale = await sdk.forProject(page.params.region, page.params.project).locale.get();
if (locale?.countryCode) {
country = locale.countryCode;
}
const countryList = await sdk
.forProject(page.params.region, page.params.project)
.locale.listCountries();
options = countryList.countries.map((country) => {
return {
value: country.code,
@@ -1,7 +1,7 @@
import { page } from '$app/stores';
import type { WizardStepsType } from '$lib/layout/wizardWithSteps.svelte';
import type { AggregationList, Invoice } from '$lib/sdk/billing';
import { derived, writable } from 'svelte/store';
import type { WizardStepsType } from '$lib/layout/wizardWithSteps.svelte';
import type { AggregationList, Invoice, InvoiceUsage } from '$lib/sdk/billing';
export const aggregationList = derived(
page,
@@ -16,3 +16,31 @@ export const addCreditWizardStore = writable<{ coupon: string; paymentMethodId:
export const selectedInvoice = writable<Invoice>(null);
export const showRetryModal = writable(false);
export type RowFactoryOptions = {
id: string;
label: string;
resource?: InvoiceUsage;
planLimit?: number | null;
includeProgress?: boolean;
formatValue?: (value: number | null | undefined) => string;
usageFormatter?: (options: {
value: number;
planLimit?: number | null;
resource?: InvoiceUsage;
formatValue: (value: number | null | undefined) => string;
hasLimit: boolean;
}) => string;
priceFormatter?: (options: { amount: number; resource?: InvoiceUsage }) => string;
progressFactory?: (options: {
value: number;
planLimit?: number | null;
resource?: InvoiceUsage;
hasLimit: boolean;
}) => Array<{ size: number; color: string; tooltip?: { title: string; label: string } }>;
maxFactory?: (options: {
planLimit?: number | null;
hasLimit: boolean;
resource?: InvoiceUsage;
}) => number | null;
};
@@ -16,7 +16,6 @@
import { sdk } from '$lib/stores/sdk';
import { confirmPayment } from '$lib/stores/stripe';
import { user } from '$lib/stores/user';
import { VARS } from '$lib/system';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import {
Alert,
@@ -142,30 +141,14 @@
}
async function trackDowngradeFeedback() {
const paidInvoices = await sdk.forConsole.billing.listInvoices(data.organization.$id, [
Query.equal('status', 'succeeded'),
Query.greaterThan('grossAmount', 0)
]);
await fetch(`${VARS.GROWTH_ENDPOINT}/feedback/billing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: tierToPlan(data.organization.billingPlan).name,
to: tierToPlan(selectedPlan).name,
email: data.account.email,
reason: feedbackDowngradeOptions.find(
(option) => option.value === feedbackDowngradeReason
)?.label,
orgId: data.organization.$id,
userId: data.account.$id,
orgAge: data.organization.$createdAt,
userAge: data.account.$createdAt,
paidInvoices: paidInvoices.total,
message: feedbackMessage ?? ''
})
await sdk.forConsole.organizations.createDowngradeFeedback({
organizationId: data.organization.$id,
reason: feedbackDowngradeOptions.find(
(option) => option.value === feedbackDowngradeReason
)?.label,
message: feedbackMessage ?? '',
fromPlanId: data.organization.billingPlan,
toPlanId: selectedPlan
});
}
@@ -175,8 +158,7 @@
await sdk.forConsole.billing.updatePlan(
data.organization.$id,
selectedPlan,
paymentMethodId,
null
paymentMethodId
);
// 2) If the target plan has a project limit, apply selected projects now
@@ -256,7 +238,7 @@
data.organization.$id,
selectedPlan,
paymentMethodId,
null,
undefined,
selectedCoupon?.code,
newCollaborators,
billingBudget,
@@ -48,27 +48,29 @@ export const load: LayoutLoad = async ({ params, route, depends, parent }) => {
// fast path without a network call!
let organization = organizations?.teams?.find((org) => org.$id === project.teamId);
const includedInBasePlans = plansInfo.has(organization.billingPlan);
// organization can be null if not in the filtered list!
const includedInBasePlans = plansInfo.has(organization?.billingPlan);
const [org, regionalConsoleVariables, rolesResult, organizationPlan] = await Promise.all([
const [org, regionalConsoleVariables, rolesResult] = await Promise.all([
!organization
? (sdk.forConsole.teams.get({ teamId: project.teamId }) as Promise<Organization>)
: organization,
sdk.forConsoleIn(project.region).console.variables(),
isCloud ? sdk.forConsole.billing.getRoles(project.teamId) : null,
// fetch if not available in `plansInfo`
includedInBasePlans
? plansInfo.get(organization.billingPlan)
: isCloud
? sdk.forConsole.billing.getOrganizationPlan(organization.$id)
: null,
loadAvailableRegions(project.teamId)
]);
if (!organization) organization = org;
// fetch if not available in `plansInfo`.
// out of promise.all because we filter orgs based on platform now!
const organizationPlan = includedInBasePlans
? plansInfo.get(organization?.billingPlan)
: isCloud
? await sdk.forConsole.billing.getOrganizationPlan(organization?.$id)
: null;
const roles = rolesResult?.roles ?? defaultRoles;
const scopes = rolesResult?.scopes ?? defaultScopes;
@@ -38,8 +38,9 @@
}
};
$: secret =
clientSecret && tenantID ? JSON.stringify({ clientSecret, tenantID }) : provider.secret;
$: secret = clientSecret
? JSON.stringify({ clientSecret, ...(tenantID && { tenantID }) })
: provider.secret;
</script>
<Modal {error} onSubmit={update} bind:show on:close title={`${provider.name} OAuth2 settings`}>
@@ -99,7 +99,7 @@
Learn more</Link.Anchor>
<svelte:fragment slot="aside">
{#if isComponentDisabled}
<EmptyCardImageCloud source="email_signature_card">
<EmptyCardImageCloud responsive source="email_signature_card">
<svelte:fragment slot="image">
<div class=" is-only-mobile u-width-full-line u-height-100-percent">
{#if $app.themeInUse === 'dark'}
@@ -16,7 +16,7 @@
Enable or disable {resolvedProfile.platform} branding in your email template signature.
<svelte:fragment slot="aside">
<EmptyCardImageCloud source="email_signature_card" let:nextTier>
<EmptyCardImageCloud responsive source="email_signature_card" let:nextTier>
<svelte:fragment slot="image">
<div class=" is-only-mobile u-width-full-line u-height-100-percent">
{#if $app.themeInUse === 'dark'}
@@ -7,14 +7,13 @@
import { ID } from '@appwrite.io/console';
import { createEventDispatcher } from 'svelte';
import { isCloud } from '$lib/system';
import { organization } from '$lib/stores/organization';
import { currentPlan } from '$lib/stores/organization';
import { upgradeURL } from '$lib/stores/billing';
import CreatePolicy from './database-[database]/backups/createPolicy.svelte';
import { cronExpression, type UserBackupPolicy } from '$lib/helpers/backups';
import { Alert, Icon, Tag } from '@appwrite.io/pink-svelte';
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
import { page } from '$app/state';
import { isFreePlan } from '$lib/helpers/billing';
export let showCreate = false;
let totalPolicies: UserBackupPolicy[] = [];
@@ -132,7 +131,7 @@
<CustomId bind:show={showCustomId} name="Database" bind:id autofocus={false} />
{#if isCloud}
{#if isFreePlan($organization?.billingPlan)}
{#if !$currentPlan?.backupsEnabled}
<Alert.Inline title="This database won't be backed up" status="warning">
Upgrade your plan to ensure your data stays safe and backed up.
<svelte:fragment slot="actions">
@@ -82,9 +82,4 @@
aspect-ratio: 1/1;
}
}
:global(.ai-icon-holder.notification) {
width: 36px !important;
height: 32px !important;
}
</style>
@@ -135,6 +135,7 @@ export type ColumnInput = {
formatOptions?: {
min?: number;
max?: number;
elements?: string[];
};
};
@@ -155,7 +156,10 @@ export function mapSuggestedColumns<T extends ColumnInput>(columns: T[]): Sugges
? (col.max ?? col.formatOptions?.max ?? undefined)
: undefined,
format: col.format ?? null,
elements: col.elements ?? undefined
elements:
col.format === 'enum'
? (col.elements ?? col.formatOptions?.elements ?? undefined)
: undefined
}));
}
@@ -5,8 +5,6 @@
import { Badge, Icon, Layout, Tag, Typography } from '@appwrite.io/pink-svelte';
import { goto } from '$app/navigation';
import { upgradeURL } from '$lib/stores/billing';
import { BillingPlan } from '$lib/constants';
import { organization } from '$lib/stores/organization';
export let isFlex = true;
export let title: string;
@@ -50,7 +48,7 @@
paddingBlock="var(--space-5, 12px)"
paddingInline="var(--space-6, 16px)"
resetListPadding>
{#if $organization?.billingPlan === BillingPlan.PRO}
{#if maxPolicies === 1}
<Tag
size="s"
style="white-space: nowrap; max-width: none;"
@@ -20,8 +20,7 @@
type UserBackupPolicy
} from '$lib/helpers/backups';
import { InputNumber } from '$lib/elements/forms/index.js';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
import { currentPlan } from '$lib/stores/organization';
import { Card, Icon, Layout, Link, Tag, Typography } from '@appwrite.io/pink-svelte';
import { IconPencil, IconTrash } from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
@@ -149,7 +148,7 @@
);
// pre-check the hourly if on pro plan
if ($organization.billingPlan === BillingPlan.PRO && isFromBackupsTab) {
if ($currentPlan?.backupPolicies === 1 && isFromBackupsTab) {
presetPolicies.update((all) =>
all.map((policy) => {
policy.id = ID.unique();
@@ -176,7 +175,7 @@
</script>
<div class="u-flex-vertical u-gap-16">
{#if $organization.billingPlan === BillingPlan.SCALE}
{#if $currentPlan?.backupPolicies > 1}
{#if title || subtitle}
<div class="body-text-2">
{#if title}
@@ -195,7 +194,7 @@
{/if}
<!-- because we show a set of pre-defined ones -->
{#if $organization.billingPlan === BillingPlan.PRO}
{#if $currentPlan?.backupPolicies === 1}
{@const dailyPolicy = $presetPolicies[1]}
{#if isFromBackupsTab}
@@ -71,7 +71,8 @@
// spatial type selected -> reset column list to single empty column
// and the column already is not spatial type
$effect(() => {
if (selectedType === IndexType.Spatial && !columnList.at(0).value) {
const firstColumn = $table.columns.find((col) => col.key === columnList.at(0)?.value);
if (selectedType === IndexType.Spatial && firstColumn && !isSpatialType(firstColumn)) {
columnList = [{ value: '', order: null, length: null }];
}
});
@@ -251,6 +251,7 @@
data-mode={mode}
bind:this={spreadsheetContainer}
class:custom-columns={customColumns.length > 0}
class:no-custom-columns={customColumns.length <= 0}
class="databases-spreadsheet spreadsheet-container-outer">
<SpreadsheetContainer>
<Spreadsheet.Root
@@ -381,6 +382,19 @@
width: unset;
}
&.no-custom-columns {
@media (max-width: 768px) {
& :global(.spreadsheet-wrapper) {
opacity: 0;
}
& > .spreadsheet-fade-bottom {
top: var(--top-actions-spacing) !important;
background: var(--bgcolor-neutral-primary) !important;
}
}
}
&:not(.custom-columns) :global(.spreadsheet-container) {
overflow-x: hidden;
overflow-y: hidden;
@@ -393,12 +407,16 @@
}
&[data-mode='rows'] {
--top-actions-spacing: 50%;
& :global([role='rowheader'] :nth-last-child(2) [role='presentation']) {
display: none;
}
}
&[data-mode='indexes'] {
--top-actions-spacing: 40%;
& :global([role='cell']:last-child [role='presentation']) {
display: none;
}
@@ -463,4 +481,11 @@
margin-bottom: 15%;
}
}
@media (max-width: 768px) {
// global but controlled properly!
:global(main:has(.no-custom-columns) .console-container) {
opacity: 0;
}
}
</style>
@@ -11,7 +11,7 @@
import { Fieldset, Layout, Icon, Input, Tag } from '@appwrite.io/pink-svelte';
import { IconGithub, IconPencil } from '@appwrite.io/pink-icons-svelte';
import { onMount } from 'svelte';
import { ID, Runtime } from '@appwrite.io/console';
import { ID, Runtime, Type } from '@appwrite.io/console';
import { CustomId } from '$lib/components';
import { getIconFromRuntime } from '$lib/stores/runtimes';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
@@ -135,7 +135,8 @@
repository: data.repository.name,
owner: data.repository.owner,
rootDirectory: rootDir || '.',
version: latestTag ?? '1.0.0',
type: Type.Tag,
reference: latestTag ?? '1.0.0',
activate: true
});
@@ -15,7 +15,7 @@
import { writable } from 'svelte/store';
import ProductionBranch from '$lib/components/git/productionBranchFieldset.svelte';
import Configuration from './configuration.svelte';
import { ID, Runtime, type Models } from '@appwrite.io/console';
import { ID, Runtime, Type, type Models } from '@appwrite.io/console';
import {
ConnectBehaviour,
NewRepository,
@@ -180,7 +180,8 @@
repository: data.template.providerRepositoryId || undefined,
owner: data.template.providerOwner || undefined,
rootDirectory: rt?.providerRootDirectory || undefined,
version: data.template.providerVersion || undefined,
type: Type.Tag,
reference: data.template.providerVersion || undefined,
activate: true
});
@@ -1,4 +1,4 @@
import { Query } from '@appwrite.io/console';
import { Query, type Models } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { getLimit, getPage, getQuery, pageToOffset } from '$lib/helpers/load';
import { Dependencies, PAGE_LIMIT } from '$lib/constants';
@@ -15,17 +15,27 @@ export const load: PageLoad = async ({ params, depends, url, route, parent }) =>
const parsedQueries = queryParamToMap(query || '[]');
queries.set(parsedQueries);
let activeDeployment: Models.Deployment | null = null;
if (data.function.deploymentId) {
try {
activeDeployment = await sdk
.forProject(params.region, params.project)
.functions.getDeployment({
functionId: params.function,
deploymentId: data.function.deploymentId
});
} catch (error) {
// active deployment with the requested ID could not be found
activeDeployment = null;
}
}
return {
offset,
limit,
query,
installations: data.installations,
activeDeployment: data.function.deploymentId
? await sdk.forProject(params.region, params.project).functions.getDeployment({
functionId: params.function,
deploymentId: data.function.deploymentId
})
: null,
activeDeployment,
deploymentList: await sdk
.forProject(params.region, params.project)
.functions.listDeployments({
@@ -41,7 +51,15 @@ export const load: PageLoad = async ({ params, depends, url, route, parent }) =>
'buildDuration',
'status',
'type',
'resourceId'
'resourceId',
'providerRepositoryUrl',
'providerRepositoryOwner',
'providerRepositoryName',
'providerBranchUrl',
'providerBranch',
'providerCommitMessage',
'providerCommitHash',
'providerCommitUrl'
]),
...parsedQueries.values()
]
@@ -24,8 +24,8 @@
let isSubmitting = writable(false);
let scopes: string[] = [];
let name = '',
expire = '';
let name = '';
let expire: string | null = null;
async function create() {
try {
@@ -26,17 +26,16 @@
</script>
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { scopes as allScopes } from '$lib/constants';
import { onMount } from 'svelte';
import { isCloud } from '$lib/system';
import { Button } from '$lib/elements/forms';
import { symmetricDifference } from '$lib/helpers/array';
import { scopes as allScopes, cloudOnlyBackupScopes } from '$lib/constants';
import { Accordion, Divider, Layout, Selector } from '@appwrite.io/pink-svelte';
export let scopes: string[];
const scopeCatalog = new Set(allScopes.map((s) => s.scope));
const filteredScopes = allScopes.filter((scope) => {
const baseFilteredScopes = allScopes.filter((scope) => {
const val = scope.scope;
if (!val) return false;
@@ -44,6 +43,23 @@
return !legacyPrefixes.some((prefix) => val.startsWith(prefix));
});
// insert cloud-only scopes right after databases.write
const databasesWriteIndex = baseFilteredScopes.findIndex((s) => s.scope === 'databases.write');
const filteredScopes =
isCloud && databasesWriteIndex !== -1
? [
...baseFilteredScopes.slice(0, databasesWriteIndex + 1),
...cloudOnlyBackupScopes,
...baseFilteredScopes.slice(databasesWriteIndex + 1)
]
: baseFilteredScopes;
// include all scopes
const scopeCatalog = new Set([
...allScopes.map((s) => s.scope),
...(isCloud ? cloudOnlyBackupScopes.map((s) => s.scope) : [])
]);
enum Category {
Auth = 'Auth',
Database = 'Database',
@@ -0,0 +1,7 @@
<script lang="ts">
import { app } from '$lib/stores/app';
import Light from '../assets/cursor-ai.svg';
import Dark from '../assets/dark/cursor-ai.svg';
</script>
<img src={$app.themeInUse === 'dark' ? Dark : Light} width="20" height="20" alt="Cursor" />
@@ -17,7 +17,7 @@
import { Card } from '$lib/components';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { realtime, sdk } from '$lib/stores/sdk';
import { getApiEndpoint, realtime, sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { fade } from 'svelte/transition';
@@ -26,6 +26,7 @@
import { PlatformType } from '@appwrite.io/console';
import { project } from '../../store';
import { getCorrectTitle, type PlatformProps } from './store';
import LlmBanner from './llmBanner.svelte';
let { isConnectPlatform = false }: PlatformProps = $props();
@@ -35,6 +36,60 @@
let isPlatformCreated = $state(isConnectPlatform);
const projectId = page.params.project;
const VERSIONS_ENDPOINT = (() => {
const endpoint = getApiEndpoint(page.params.region);
const url = new URL('/versions', endpoint);
return url.toString();
})();
let androidSdkVersion = $state('11.3.0');
function buildAndroidInstructions(version: string) {
return `
Confirm you're working inside the correct Android project before editing anything:
- Navigate into the directory that contains the real Android app module (look for gradlew, settings.gradle, and the app-level build.gradle(.kts)).
- If Cursor opens in a parent folder (like your home directory) or you see multiple Android projects, ask which one to modify before making changes.
- Update the app-level build.gradle.kts by default, but be ready to edit a Groovy build.gradle if the project hasn't migrated to Kotlin DSL yet.
Prefer Version Catalogs when adding the Appwrite SDK:
1. If ./gradle/libs.versions.toml exists, add or reuse an Appwrite entry:
\`\`\`toml
[libraries]
appwrite = { module = "io.appwrite:sdk-for-android", version = "${version}" }
\`\`\`
2. Reference it inside the module's dependencies block:
\`\`\`kotlin
dependencies {
implementation(libs.appwrite)
}
\`\`\`
Only when the project lacks ./gradle/libs.versions.toml should you hardcode the dependency:
\`\`\`kotlin
implementation("io.appwrite:sdk-for-android:${version}")
\`\`\`
Legacy Groovy scripts should use:
\`\`\`groovy
implementation "io.appwrite:sdk-for-android:${version}"
\`\`\`
Before introducing any new files, search the project (app/src, libs/, shared modules, etc.) for existing Appwrite client helpers (look for \`Client(\`, \`AppwriteClient\`, or \`.setEndpoint\`). If a client already exists, update its configuration instead of creating a duplicate.
Ensure the Appwrite client is initialized with the application context and current project info:
\`\`\`kotlin
val client = Client(applicationContext)
.setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}")
.setProject("${projectId}")
val account = Account(client)
\`\`\`
From the app's entry point (e.g., Application class or the first launched Activity), automatically invoke a helper that pings Appwrite so the user can verify connectivity and will be reflected on the Appwrite console:
\`\`\`kotlin
client.ping()
\`\`\`
`;
}
const alreadyExistsInstructions = $derived(buildAndroidInstructions(androidSdkVersion));
const gitCloneCode =
'\ngit clone https://github.com/appwrite/starter-for-android\ncd starter-for-android\n';
@@ -43,6 +98,22 @@
const val APPWRITE_PROJECT_NAME = "${$project.name}"
const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}"`;
async function fetchAndroidSdkVersion() {
try {
const response = await fetch(VERSIONS_ENDPOINT);
if (!response.ok) {
throw new Error(`Failed to fetch versions: ${response.status}`);
}
const data = await response.json();
const latestVersion = data?.['client-android'];
if (typeof latestVersion === 'string' && latestVersion.trim()) {
androidSdkVersion = latestVersion.trim();
}
} catch (error) {
console.error('Unable to fetch latest Android SDK version', error);
}
}
async function createAndroidPlatform() {
try {
isCreatingPlatform = true;
@@ -83,6 +154,7 @@ const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page.
}
onMount(() => {
fetchAndroidSdkVersion();
const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
@@ -171,6 +243,12 @@ const val APPWRITE_PUBLIC_ENDPOINT = "${sdk.forProject(page.params.region, page.
{#if isPlatformCreated}
<Fieldset legend="Clone starter" badge="Optional">
<Layout.Stack gap="l">
<LlmBanner
platform="android"
{configCode}
{alreadyExistsInstructions}
openers={['cursor']} />
<Typography.Text variant="m-500">
1. If you're starting a new project, you can clone our starter kit from
GitHub using the terminal, VSCode or Android Studio.
@@ -28,6 +28,7 @@
import { app } from '$lib/stores/app';
import { project } from '../../store';
import { getCorrectTitle, type PlatformProps } from './store';
import LlmBanner from './llmBanner.svelte';
let { isConnectPlatform = false, platform = PlatformType.Appleios }: PlatformProps = $props();
@@ -38,6 +39,30 @@
const projectId = page.params.project;
const alreadyExistsInstructions = `
Install the Appwrite iOS SDK using the following package URL:
\`\`\`
https://github.com/appwrite/sdk-for-apple
\`\`\`
From a suitable lib directory, export the Appwrite client as a global variable:
\`\`\`
let client = Client()
.setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}")
.setProject("${projectId}")
let account = Account(client)
\`\`\`
On the homepage of the app, create a button that says "Send a ping" and when clicked, it should call the following function:
\`\`\`
client.ping()
\`\`\`
`;
const gitCloneCode =
'\ngit clone https://github.com/appwrite/starter-for-ios\ncd starter-for-ios\n';
@@ -45,7 +70,7 @@
APPWRITE_PROJECT_NAME: "${$project.name}"
APPWRITE_PUBLIC_ENDPOINT: "${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}"`;
let platforms: { [key: string]: PlatformType } = {
const platforms: { [key: string]: PlatformType } = {
iOS: PlatformType.Appleios,
macOS: PlatformType.Applemacos,
watchOS: PlatformType.Applewatchos,
@@ -199,6 +224,12 @@ APPWRITE_PUBLIC_ENDPOINT: "${sdk.forProject(page.params.region, page.params.proj
{#if isPlatformCreated}
<Fieldset legend="Clone starter" badge="Optional">
<Layout.Stack gap="l">
<LlmBanner
platform="apple"
{configCode}
{alreadyExistsInstructions}
openers={['cursor']} />
<Typography.Text variant="m-500">
1. If you're starting a new project, you can clone our starter kit from
GitHub using the terminal or XCode.
@@ -18,7 +18,7 @@
import { Card } from '$lib/components';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { realtime, sdk } from '$lib/stores/sdk';
import { getApiEndpoint, realtime, sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { fade } from 'svelte/transition';
@@ -28,6 +28,7 @@
import { project } from '../../store';
import { resolvedProfile } from '$lib/profiles/index.svelte';
import { getCorrectTitle, type PlatformProps } from './store';
import LlmBanner from './llmBanner.svelte';
let { isConnectPlatform = false, platform = PlatformType.Flutterandroid }: PlatformProps =
$props();
@@ -38,6 +39,38 @@
let isPlatformCreated = $state(isConnectPlatform);
const projectId = page.params.project;
const VERSIONS_ENDPOINT = (() => {
const endpoint = getApiEndpoint(page.params.region);
const url = new URL('/versions', endpoint);
return url.toString();
})();
let flutterSdkVersion = $state('20.3.0');
function buildFlutterInstructions(version: string) {
return `
Install the Appwrite Flutter SDK using the following command:
\`\`\`
flutter pub add appwrite:${version}
\`\`\`
From a suitable lib directory, export the Appwrite client as a global variable, hardcode the project details too:
\`\`\`
final Client client = Client()
.setProject("${projectId}")
.setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}");
\`\`\`
On the homepage of the app, create a button that says "Send a ping" and when clicked, it should call the following function:
\`\`\`
client.ping();
\`\`\`
`;
}
const alreadyExistsInstructions = $derived(buildFlutterInstructions(flutterSdkVersion));
const gitCloneCode =
'\ngit clone https://github.com/appwrite/starter-for-flutter\ncd starter-for-flutter\n';
@@ -111,6 +144,22 @@
[PlatformType.Flutterwindows]: 'Package name'
};
async function fetchFlutterSdkVersion() {
try {
const response = await fetch(VERSIONS_ENDPOINT);
if (!response.ok) {
throw new Error(`Failed to fetch versions: ${response.status}`);
}
const data = await response.json();
const latestVersion = data?.['client-flutter'];
if (typeof latestVersion === 'string' && latestVersion.trim()) {
flutterSdkVersion = latestVersion.trim();
}
} catch (error) {
console.error('Unable to fetch latest Flutter SDK version', error);
}
}
async function createFlutterPlatform() {
try {
isCreatingPlatform = true;
@@ -158,6 +207,7 @@
}
onMount(() => {
fetchFlutterSdkVersion();
const unsubscribe = realtime.forConsole(page.params.region, 'console', (response) => {
if (response.events.includes(`projects.${projectId}.ping`)) {
connectionSuccessful = true;
@@ -281,6 +331,11 @@
{#if isPlatformCreated}
<Fieldset legend="Clone starter" badge="Optional">
<Layout.Stack gap="l">
<LlmBanner
platform="flutter"
{configCode}
{alreadyExistsInstructions}
openers={['cursor']} />
<Typography.Text variant="m-500">
1. If you're starting a new project, you can clone our starter kit from
GitHub using the terminal, VSCode or Android Studio.
@@ -28,6 +28,7 @@
import { project } from '../../store';
import { resolvedProfile } from '$lib/profiles/index.svelte';
import { getCorrectTitle, type PlatformProps } from './store';
import LlmBanner from './llmBanner.svelte';
let { isConnectPlatform = false, platform = PlatformType.Reactnativeandroid }: PlatformProps =
$props();
@@ -39,6 +40,28 @@
const projectId = page.params.project;
const alreadyExistsInstructions = `
Install the Appwrite React Native SDK using the following command, respect user's package manager of choice and use the one being used in the codebase:
\`\`\`
npx expo install react-native-appwrite react-native-url-polyfill
\`\`\`
From a suitable lib directory, export the Appwrite client as a global variable, hardcode the project details too:
\`\`\`
const client = new Client()
.setProject("${projectId}")
.setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}");
\`\`\`
From the entrypoint of the app, make it so that the following function is automatically called which will ping the Appwrite backend server to verify the setup. Let the user know about this function being added
\`\`\`
client.ping();
\`\`\`
`;
const gitCloneCode =
'\ngit clone https://github.com/appwrite/starter-for-react-native\ncd starter-for-react-native\n';
@@ -46,6 +69,12 @@
EXPO_PUBLIC_APPWRITE_PROJECT_NAME="${$project.name}"
EXPO_PUBLIC_APPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}`;
const promptConfigCode = `
const client = new Client()
.setProject("${projectId}")
.setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}")
`;
let platforms: { [key: string]: PlatformType } = {
Android: PlatformType.Reactnativeandroid,
iOS: PlatformType.Reactnativeios
@@ -226,6 +255,12 @@ EXPO_PUBLIC_APPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.p
{#if isPlatformCreated}
<Fieldset legend="Clone starter" badge="Optional">
<Layout.Stack gap="l">
<LlmBanner
platform="reactnative"
configCode={promptConfigCode}
{alreadyExistsInstructions}
openers={['cursor']} />
<Typography.Text variant="m-500">
1. If you're starting a new project, you can clone our starter kit from
GitHub using the terminal or VSCode.
@@ -48,8 +48,14 @@
} from './components/index';
import { extendedHostnameRegex } from '$lib/helpers/string';
import { project } from '../../store';
import {
type PlatformProps,
type FrameworkType,
type LLMPromptConfig,
getCorrectTitle
} from './store';
import LlmBanner from './llmBanner.svelte';
import { resolvedProfile } from '$lib/profiles/index.svelte';
import { type PlatformProps, type FrameworkType, getCorrectTitle } from './store';
let { key, isConnectPlatform = false, platform = PlatformType.Web }: PlatformProps = $props();
@@ -158,6 +164,57 @@ ${prefix}APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.p
selectedFramework ? selectedFramework.icon : NoFrameworkIcon
);
const llmConfig: LLMPromptConfig = $derived({
alreadyExistsInstructions: `
Install the Appwrite web SDK using the following command. Respect the user's package manager of choice. Do not use NPM if the user uses Bun for example.
\`\`\`bash
npm install appwrite
\`\`\`
Create a new \`appwrite.js\` (or equivalent, respecting the framework and language, don't create a JS file if TS is being used in the project) file in a suitable lib directory and have the following code:
\`\`\`js
import { Client, Account, Databases } from "appwrite";
const client = new Client()
.setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}")
.setProject("${projectId}");
const account = new Account(client);
const databases = new Databases(client);
export { client, account, databases };
\`\`\`
When the app is opened, make it so that the following function is automatically called which will ping the Appwrite backend server to verify the setup. Let the user know about this function being added
\`\`\`js
client.ping();
\`\`\`
`,
title: `Copy prompt: starter kit for Appwrite in ${selectedFramework?.label || 'Web'}`,
cloneCommand: `git clone https://github.com/appwrite/starter-for-${selectedFramework?.key}\ncd starter-for-${selectedFramework?.key}`,
configFile:
selectedFramework?.key === 'angular'
? 'src/environments/environment.ts'
: 'appwrite.js',
configCode:
// selectedFramework?.key === 'angular'
// ? `APPWRITE_PROJECT_ID=${projectId}\nAPPWRITE_PROJECT_NAME=${$project.name}\nAPPWRITE_ENDPOINT=${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}`
// : `
// const client = new Client()
// .setEndpoint("${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}")
// .setProject("${projectId}");
// `,
`APPWRITE_PROJECT_ID = "${projectId}"
APPWRITE_PROJECT_NAME = "${$project.name}"
APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.project).client.config.endpoint}"`,
configLanguage: selectedFramework?.key === 'angular' ? 'ts' : 'dotenv',
runInstructions: `Install project dependencies using \`npm install\`, then run the app using \`${selectedFramework?.runCommand}\`. Demo app runs on http://localhost:${selectedFramework?.portNumber}. Click the \`Send a ping\` button to verify the setup.`,
using: 'the terminal or VSCode'
});
async function createWebPlatform() {
const hostnameRegex = new RegExp(extendedHostnameRegex);
const finalHostname = hostname?.trim() || 'localhost';
@@ -302,6 +359,8 @@ ${prefix}APPWRITE_ENDPOINT = "${sdk.forProject(page.params.region, page.params.p
{#if isPlatformCreated && !isChangingFramework}
<Fieldset legend="Clone starter" badge="Optional">
<Layout.Stack gap="l">
<LlmBanner config={llmConfig} openers={['cursor', 'lovable']} />
<Typography.Text variant="m-500">
1. If you're starting a new project, you can clone our starter kit from
GitHub using the terminal or VSCode.
@@ -0,0 +1,188 @@
<script lang="ts">
import { copy } from '$lib/helpers/copy';
import { Button } from '$lib/elements/forms';
import {
ActionMenu,
Alert,
Icon,
Layout,
Popover,
Typography,
Button as PinkButton
} from '@appwrite.io/pink-svelte';
import { IconChevronDown, IconChevronUp, IconLovable } from '@appwrite.io/pink-icons-svelte';
import { addNotification } from '$lib/stores/notifications';
import { buildPlatformConfig, generatePromptFromConfig, type LLMPromptConfig } from './store';
import { Click, trackEvent } from '$lib/actions/analytics';
import IconAINotification from '../../databases/database-[database]/(suggestions)/icon/aiNotification.svelte';
import Avatar from '$lib/components/avatar.svelte';
import CursorIcon from '$routes/(console)/project-[region]-[project]/overview/components/CursorIconLarge.svelte';
import type { ComponentType } from 'svelte';
let {
platform,
configCode,
alreadyExistsInstructions,
config: customConfig,
openers = [] as Array<SupportedAgents>
}: {
platform?: string;
configCode?: string;
alreadyExistsInstructions?: string;
config?: LLMPromptConfig;
openers?: Array<SupportedAgents>;
} = $props();
const config = $derived.by(() => {
if (customConfig) return customConfig;
if (platform && configCode)
return buildPlatformConfig(platform, configCode, alreadyExistsInstructions);
throw new Error('LlmBanner: must provide either config OR (platform + configCode)');
});
const prompt = $derived(generatePromptFromConfig(config));
let showAlert = $state(true);
type OpenerConfig = {
id: SupportedAgents;
label: string;
description: string;
href: (prompt: string) => string;
icon?: ComponentType;
imgSrc?: string;
alt: string;
};
type SupportedAgents = 'cursor' | 'lovable';
const openersConfig: Record<SupportedAgents, OpenerConfig> = {
cursor: {
id: 'cursor',
label: 'Open in Cursor',
description: 'Set up starter kit in Cursor',
href: (p: string) => {
trackEvent(Click.OpenInCursorClick, {
platform: config.title
});
const u = new URL('https://cursor.com/link/prompt');
u.searchParams.set('text', p);
return u.toString();
},
icon: CursorIcon,
alt: 'Cursor'
},
lovable: {
id: 'lovable',
label: 'Open in Lovable',
description: 'Set up starter kit in Lovable',
href: (p: string) => {
trackEvent(Click.OpenInLovableClick, {
platform: config.title
});
const u = new URL('https://lovable.dev/');
u.searchParams.set('autosubmit', 'true');
u.searchParams.set('prompt', p);
return u.toString();
},
icon: IconLovable,
alt: 'Lovable'
}
};
const validOpeners = $derived(openers.filter((id) => openersConfig[id]));
async function copyPrompt() {
await copy(prompt);
trackEvent(Click.CopyPromptStarterKitClick, {
platform: config.title
});
addNotification({
type: 'success',
message: 'Prompt copied to clipboard'
});
}
</script>
{#if showAlert}
<Alert.Inline
status="info"
title="Set up your starter kit with AI"
dismissible
--bgcolor-neutral-default="var(--bgcolor-neutral-primary)"
--fgcolor-info="var(--fgcolor-neutral-primary)"
on:dismiss={() => (showAlert = false)}>
<svelte:fragment slot="icon">
<IconAINotification />
</svelte:fragment>
<Layout.Stack direction="column" class="alert-content" gap="l">
<Layout.Stack direction="column" alignItems="center" gap="s">
<Typography.Text>
Copy the prompt or open it directly in an AI tool like Cursor or Lovable to get
step-by-step instructions, starter code, and SDK commands for your project.
</Typography.Text>
</Layout.Stack>
<Popover let:toggle let:showing padding="none" placement="bottom-start">
<svelte:fragment slot="tooltip" let:toggle>
<ActionMenu.Root>
{#each validOpeners as openerId}
{@const o = openersConfig[openerId]}
{#if o}
<ActionMenu.Item.Button
on:click={(e) => {
window.open(
o.href(prompt),
'_blank',
'noopener,noreferrer'
);
toggle(e);
}}>
<Layout.Stack direction="row" gap="s" alignItems="center">
<Avatar size="s" alt={o.alt}>
{#if o.icon}
<Icon icon={o.icon} size="l" />
{:else if o.imgSrc}
<img src={o.imgSrc} alt={o.alt} />
{/if}
</Avatar>
<Layout.Stack gap="none">
<Typography.Text
color="--fgcolor-neutral-secondary"
variant="m-500">{o.label}</Typography.Text>
<Typography.Text color="--fgcolor-neutral-tertiary">
{o.description}
</Typography.Text>
</Layout.Stack>
</Layout.Stack>
</ActionMenu.Item.Button>
{/if}
{/each}
</ActionMenu.Root>
</svelte:fragment>
<PinkButton.Split>
<Button
secondary
size="s"
class={validOpeners.length ? 'btn-no-right-radius' : ''}
on:click={copyPrompt}
disabled={!prompt || prompt.length === 0}>Copy setup prompt</Button>
{#if validOpeners.length}
<Button
secondary
size="s"
class="btn-no-left-radius"
icon
on:click={toggle}
ariaLabel="Open action menu"
disabled={!prompt || prompt.length === 0}>
<Icon icon={showing ? IconChevronUp : IconChevronDown} />
</Button>
{/if}
</PinkButton.Split>
</Popover>
</Layout.Stack>
</Alert.Inline>
{/if}
@@ -17,6 +17,119 @@ export type FrameworkType = {
updateConfigCode: string;
};
export type LLMPromptConfig = {
title: string;
alreadyExistsInstructions: string;
cloneCommand: string;
configFile: string;
configCode: string;
configLanguage: string;
runInstructions: string;
using: string;
};
export function getCorrectTitle(isConnectPlatform: boolean, platform: string) {
return isConnectPlatform ? `Connect your ${platform} app` : `Add ${platform} platform`;
}
export function generatePromptFromConfig(config: LLMPromptConfig): string {
return `
Goal: Setting up Appwrite SDK in the project depending on if a project already exists or not.
Following are the project details:
\`\`\`
${config.configCode}
\`\`\`
Follow the steps depending on if a project already exists on user's working directory or not:
## If a project already exists:
${config.alreadyExistsInstructions}
## If a project does not exist:
1. Clone the starter kit using ${config.using || 'the terminal'}. Make sure to clone in the current working directory so that the cloned files are directly available in the working directory.
\`\`\`bash
${config.cloneCommand} .
\`\`\`
2. Replace all occurrences of the environment variables described in the project details section with their corresponding values. This effectively hardcodes the project details wherever those environment variables are used. Use grep (or an equivalent search) to find and update all occurrences.
3. ${config.runInstructions}`;
}
type PlatformConfig = {
name: string;
title: string;
repoName: string;
configFile: string;
configLanguage: string;
runInstructions: string;
using: string;
};
const platformConfigs: Record<string, PlatformConfig> = {
android: {
name: 'Kotlin',
title: 'Copy prompt: starter kit for Appwrite in Kotlin',
repoName: 'starter-for-android',
configFile: 'constants/AppwriteConfig.kt',
configLanguage: 'kotlin',
runInstructions:
'Run the app on a connected device or emulator, then click the `Send a ping` button to verify the setup.',
using: 'the terminal, VSCode or Android Studio'
},
apple: {
name: 'Apple platforms',
title: 'Copy prompt: starter kit for Appwrite for Apple platforms',
repoName: 'starter-for-ios',
configFile: 'Sources/Config.plist',
configLanguage: 'plaintext',
runInstructions:
'Run the app on a connected device or simulator, then click the `Send a ping` button to verify the setup.',
using: 'the terminal or XCode'
},
flutter: {
name: 'Flutter',
title: 'Copy prompt: starter kit for Appwrite in Flutter',
repoName: 'starter-for-flutter',
configFile: 'lib/config/environment.dart',
configLanguage: 'dart',
runInstructions:
'Run the app on a connected device or simulator using `flutter run -d [device_name]`, then click the `Send a ping` button to verify the setup. Ask the user if the AI agent should run the command to run the app for them. Provide the full command while you ask for permission.',
using: 'the terminal'
},
reactnative: {
name: 'React Native',
title: 'Copy prompt: starter kit for Appwrite in React Native',
repoName: 'starter-for-react-native',
configFile: 'index.ts',
configLanguage: 'typescript',
runInstructions:
'After replacing and hardcoding project details, run the app on a connected device or simulator using `npm install` followed by `npm run ios` or `npm run android`, then click the `Send a ping` button to verify the setup. Ask the user if the AI agent should run the command to run the app for them. Provide the full command while you ask for permission.',
using: 'the terminal or VSCode'
}
};
export function buildPlatformConfig(
platformKey: string,
configCode: string,
alreadyExistsInstructions: string
): LLMPromptConfig {
const config = platformConfigs[platformKey];
if (!config) {
throw new Error(`Unknown platform: ${platformKey}`);
}
return {
title: config.title,
alreadyExistsInstructions: alreadyExistsInstructions,
cloneCommand: `git clone https://github.com/appwrite/${config.repoName}\ncd ${config.repoName}`,
configFile: config.configFile,
configCode: configCode,
configLanguage: config.configLanguage,
runInstructions: config.runInstructions,
using: config.using
};
}
@@ -12,7 +12,7 @@
import { IconGithub, IconPencil } from '@appwrite.io/pink-icons-svelte';
import { onMount } from 'svelte';
import Domain from '../domain.svelte';
import { Adapter, BuildRuntime, Framework, ID } from '@appwrite.io/console';
import { Adapter, BuildRuntime, Framework, ID, Type } from '@appwrite.io/console';
import { CustomId } from '$lib/components';
import { getFrameworkIcon } from '$lib/stores/sites';
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
@@ -173,7 +173,8 @@
repository: data.repository.name,
owner: data.repository.owner,
rootDirectory: rootDir || '.',
version: latestTag ?? '1.0.0',
type: Type.Tag,
reference: latestTag ?? '1.0.0',
activate: true
});
@@ -67,7 +67,10 @@
</Typography.Text>
</Layout.Stack>
<Layout.Stack gap="s" direction="row">
<Button href="#" secondary>Docs</Button>
<Button
href="https://appwrite.io/docs/products/sites/deploy-from-git"
external
secondary>Docs</Button>
<Button
href={`https://github.com/${data.installations.installations[0].organization}`}
text>Go to GitHub</Button>
@@ -24,7 +24,7 @@
import Details from '../../details.svelte';
import Configuration from './configuration.svelte';
import Aside from '../../aside.svelte';
import { Adapter, BuildRuntime, Framework, ID, type Models } from '@appwrite.io/console';
import { Adapter, BuildRuntime, Framework, ID, Type, type Models } from '@appwrite.io/console';
import {
ConnectBehaviour,
NewRepository,
@@ -160,7 +160,8 @@
repository: data.template.providerRepositoryId,
owner: data.template.providerOwner,
rootDirectory: framework.providerRootDirectory,
version: data.template.providerVersion,
type: Type.Tag,
reference: data.template.providerVersion,
activate: true
});
@@ -1,6 +1,6 @@
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { Query } from '@appwrite.io/console';
import { Query, type Models } from '@appwrite.io/console';
import { RuleType } from '$lib/stores/sdk';
import { DeploymentResourceType } from '$lib/stores/sdk';
@@ -15,7 +15,19 @@ export const load = async ({ params, depends, parent }) => {
queries: [
Query.limit(4),
Query.orderDesc(''),
Query.select(['status', 'type', 'resourceId'])
Query.select([
'status',
'type',
'resourceId',
'providerRepositoryUrl',
'providerRepositoryOwner',
'providerRepositoryName',
'providerBranchUrl',
'providerBranch',
'providerCommitMessage',
'providerCommitHash',
'providerCommitUrl'
])
]
}),
sdk.forProject(params.region, params.project).sites.listDeployments({
@@ -46,11 +58,18 @@ export const load = async ({ params, depends, parent }) => {
})
]);
const deployment = deploymentList?.total
? await sdk
.forProject(params.region, params.project)
.sites.getDeployment({ siteId: params.site, deploymentId: site.deploymentId })
: null;
let deployment: Models.Deployment | null = null;
if (deploymentList?.total && site.deploymentId) {
try {
deployment = await sdk
.forProject(params.region, params.project)
.sites.getDeployment({ siteId: params.site, deploymentId: site.deploymentId });
} catch (error) {
// active deployment with the requested ID could not be found
deployment = null;
}
}
return {
site,
deploymentList,
@@ -1,4 +1,4 @@
import { Query } from '@appwrite.io/console';
import { Query, type Models } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { getLimit, getPage, getQuery, pageToOffset } from '$lib/helpers/load';
import { Dependencies, PAGE_LIMIT } from '$lib/constants';
@@ -29,7 +29,15 @@ export const load = async ({ params, depends, url, route, parent }) => {
'buildDuration',
'status',
'type',
'resourceId'
'resourceId',
'providerRepositoryUrl',
'providerRepositoryOwner',
'providerRepositoryName',
'providerBranchUrl',
'providerBranch',
'providerCommitMessage',
'providerCommitHash',
'providerCommitUrl'
]),
...parsedQueries.values()
]
@@ -37,17 +45,24 @@ export const load = async ({ params, depends, url, route, parent }) => {
sdk.forProject(params.region, params.project).vcs.listInstallations()
]);
let activeDeployment: Models.Deployment | null = null;
if (site.deploymentId && deploymentList?.total) {
try {
activeDeployment = await sdk
.forProject(params.region, params.project)
.sites.getDeployment({ siteId: params.site, deploymentId: site.deploymentId });
} catch (error) {
// active deployment with the requested ID could not be found
activeDeployment = null;
}
}
return {
offset,
limit,
query,
deploymentList,
activeDeployment:
site.deploymentId && deploymentList?.total
? await sdk
.forProject(params.region, params.project)
.sites.getDeployment({ siteId: params.site, deploymentId: site.deploymentId })
: null,
activeDeployment,
installations
};
};
@@ -32,7 +32,8 @@
allowedFileExtensions: values.allowedFileExtensions,
compression: values.compression,
encryption: values.encryption,
antivirus: values.antivirus
antivirus: values.antivirus,
transformations: values.transformations
});
await invalidate(Dependencies.BUCKET);
@@ -102,7 +103,8 @@
$permissions: permissions,
encryption,
antivirus,
compression
compression,
transformations
} = data.bucket;
const compressionOptions = [
@@ -215,6 +217,18 @@
}
);
}
function updateTransformations() {
updateBucket(
data.bucket,
{
transformations
},
{
trackEventName: Submit.BucketUpdateTransformations
}
);
}
</script>
<Container>
@@ -366,6 +380,28 @@
</CardGrid>
</Form>
<Form onSubmit={updateTransformations}>
<CardGrid>
<svelte:fragment slot="title">Image transformations</svelte:fragment>
<svelte:fragment slot="aside">
<Selector.Switch
label="Image transformations"
id="transformations"
bind:checked={transformations}
description="Enabling this option allows image manipulation through the API, including resizing, cropping, and format conversion." />
</svelte:fragment>
<svelte:fragment slot="actions">
<Button
disabled={transformations === data.bucket.transformations ||
($readOnly && !GRACE_PERIOD_OVERRIDE)}
submit>
Update
</Button>
</svelte:fragment>
</CardGrid>
</Form>
<UpdateMaxFileSize currentPlan={data.currentPlan} bucket={data.bucket} />
<Form onSubmit={updateAllowedExtensions}>
+160 -25
View File
@@ -1,11 +1,17 @@
<script lang="ts">
import { Wizard } from '$lib/layout';
import { Icon, Layout, Tag, Typography, Button, Card } from '@appwrite.io/pink-svelte';
import { Icon, Input, Layout, Popover, Tag, Typography, Card } from '@appwrite.io/pink-svelte';
import { supportData, isSupportOnline } from './wizard/support/store';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { Form, InputSelect, InputText, InputTextarea } from '$lib/elements/forms/index.js';
import {
Form,
InputSelect,
InputText,
InputTextarea,
Button
} from '$lib/elements/forms/index.js';
import { Query } from '@appwrite.io/console';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
localeTimezoneName,
@@ -18,30 +24,99 @@
import { user } from '$lib/stores/user';
import { wizard } from '$lib/stores/wizard';
import { VARS } from '$lib/system';
import { onDestroy } from 'svelte';
import { IconCheckCircle, IconXCircle } from '@appwrite.io/pink-icons-svelte';
import { IconCheckCircle, IconXCircle, IconInfo } from '@appwrite.io/pink-icons-svelte';
import { resolvedProfile } from '$lib/profiles/index.svelte';
let projectOptions: Array<{ value: string; label: string }>;
let projectOptions = $state<Array<{ value: string; label: string }>>([]);
// Category options with display names
const categories = [
{ value: 'general', label: 'General' },
{ value: 'billing', label: 'Billing' },
{ value: 'technical', label: 'Technical' }
];
// Topic options based on category
const topicsByCategory = {
general: [
'Security',
'Compliance',
'Performance',
'Account',
'Project',
'Regions',
'Other'
],
billing: ['Invoice', 'Plans', 'Payment methods', 'Downgrade', 'Refund', 'Usage', 'Other'],
technical: [
'Auth',
'Databases',
'Storage',
'Functions',
'Realtime',
'Messaging',
'Migrations',
'Webhooks',
'SDKs',
'Console',
'Backups',
'Blocked project',
'Domains',
'Outage',
'Platforms',
'Sites',
'Other'
]
};
// Severity options
const severityOptions = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
{ value: 'question', label: 'Question' }
];
onMount(async () => {
const projectList = await sdk.forConsole.projects.list();
// Filter projects by organization ID using server-side queries
const projectList = await sdk.forConsole.projects.list({
queries: $organization?.$id ? [Query.equal('teamId', $organization.$id)] : []
});
projectOptions = projectList.projects.map((project) => ({
value: project.$id,
label: project.name
}));
});
// Cleanup on component destroy
onDestroy(() => {
$supportData = {
message: null,
subject: null,
category: 'general',
category: 'technical',
topic: undefined,
severity: 'question',
file: null
};
});
// Update topic options when category changes
const topicOptions = $derived(
($supportData.category ? topicsByCategory[$supportData.category] || [] : []).map(
(topic) => ({
value: topic.toLowerCase().trim().replace(/\s+/g, '-'),
label: topic
})
)
);
async function handleSubmit() {
// Create category-topic tag
const categoryTopicTag = $supportData.topic
? `${$supportData.category}-${$supportData.topic}`.toLowerCase()
: $supportData.category.toLowerCase();
const response = await fetch(`${VARS.GROWTH_ENDPOINT}/support`, {
method: 'POST',
headers: {
@@ -52,13 +127,13 @@
subject: $supportData.subject,
firstName: ($user?.name || 'Unknown').slice(0, 40),
message: $supportData.message,
tags: ['cloud'],
tags: [categoryTopicTag],
customFields: [
{ id: '41612', value: $supportData.category },
{ id: '48493', value: $user?.name ?? '' },
{ id: '48492', value: $organization?.$id ?? '' },
{ id: '48491', value: $supportData?.project ?? '' },
{ id: '48490', value: $user?.$id ?? '' }
{ id: '56023', value: $supportData?.severity ?? '' },
{ id: '56024', value: $organization?.billingPlan ?? '' }
]
})
});
@@ -84,7 +159,9 @@
$supportData = {
message: null,
subject: null,
category: 'general',
category: 'technical',
topic: undefined,
severity: undefined,
file: null,
project: null
};
@@ -99,10 +176,43 @@
endDay: 'Friday' as WeekDay
};
$: supportTimings = `${utcHourToLocaleHour(workTimings.start)} - ${utcHourToLocaleHour(workTimings.end)} ${localeTimezoneName()}`;
$: supportWeekDays = `${utcWeekDayToLocaleWeekDay(workTimings.startDay, workTimings.start)} - ${utcWeekDayToLocaleWeekDay(workTimings.endDay, workTimings.end)}`;
const supportTimings = $derived(
`${utcHourToLocaleHour(workTimings.start)} - ${utcHourToLocaleHour(workTimings.end)} ${localeTimezoneName()}`
);
const supportWeekDays = $derived(
`${utcWeekDayToLocaleWeekDay(workTimings.startDay, workTimings.start)} - ${utcWeekDayToLocaleWeekDay(workTimings.endDay, workTimings.end)}`
);
</script>
{#snippet severityPopover()}
<Popover let:toggle>
<Button extraCompact size="s" on:click={toggle}>
<Icon size="s" icon={IconInfo} />
</Button>
<div slot="tooltip" style="max-width: 400px;">
<Layout.Stack gap="s">
<Typography.Text>
<b>Critical:</b> System is down or a critical component is non-functional, causing
a complete stoppage of work or significant business impact.
</Typography.Text>
<Typography.Text>
<b>High:</b> Major functionality is impaired, but a workaround is available, or a
critical component is significantly degraded.
</Typography.Text>
<Typography.Text>
<b>Medium:</b> Minor functionality is impaired without significant business impact.
</Typography.Text>
<Typography.Text>
<b>Low:</b> Issue has minor impact on business operations; workaround is not necessary.
</Typography.Text>
<Typography.Text>
<b>Question:</b> Requests for information, general guidance, or feature requests.
</Typography.Text>
</Layout.Stack>
</div>
</Popover>
{/snippet}
<Wizard title="Contact us" confirmExit={true}>
<Form onSubmit={handleSubmit}>
<Layout.Stack gap="xl">
@@ -113,24 +223,48 @@
</Layout.Stack>
<Layout.Stack gap="s">
<Typography.Text color="--fgcolor-neutral-secondary"
>Choose a topic</Typography.Text>
>Choose a category</Typography.Text>
<Layout.Stack gap="s" direction="row">
{#each ['general', 'billing', 'technical'] as category}
{#each categories as category}
<Tag
on:click={() => {
$supportData.category = category;
if ($supportData.category !== category.value) {
$supportData.topic = undefined;
}
$supportData.category = category.value;
}}
selected={$supportData.category === category}>{category}</Tag>
selected={$supportData.category === category.value}
>{category.label}</Tag>
{/each}
</Layout.Stack>
</Layout.Stack>
<InputSelect
required
{#if topicOptions.length > 0}
{#key $supportData.category}
<Input.ComboBox
id="topic"
label="Choose a topic"
placeholder="Select topic"
bind:value={$supportData.topic}
options={topicOptions} />
{/key}
{/if}
<Input.ComboBox
id="project"
label="Choose a project"
options={projectOptions ?? []}
bind:value={$supportData.project}
placeholder="Select project" />
<InputSelect
id="severity"
label="Severity"
options={severityOptions}
bind:value={$supportData.severity}
required
placeholder="Select severity">
<div slot="info">
{@render severityPopover()}
</div>
</InputSelect>
<InputText
id="subject"
label="Subject"
@@ -143,15 +277,16 @@
bind:value={$supportData.message}
placeholder="Type here..."
label="Tell us a bit more"
required
maxlength={4096} />
<Layout.Stack direction="row" justifyContent="flex-end" gap="s">
<Button.Button
<Button
size="s"
variant="secondary"
secondary
on:click={() => {
wizard.hide();
}}>Cancel</Button.Button>
<Button.Button size="s">Submit</Button.Button>
}}>Cancel</Button>
<Button submit size="s">Submit</Button>
</Layout.Stack>
</Layout.Stack>
</Form>
+4 -1
View File
@@ -4,6 +4,8 @@ export type SupportData = {
message: string;
subject: string;
category: string;
topic?: string;
severity?: string;
file?: File | null;
project?: string;
};
@@ -11,7 +13,8 @@ export type SupportData = {
export const supportData = writable<SupportData>({
message: '',
subject: '',
category: 'general',
category: 'technical',
severity: 'question',
file: null
});
@@ -81,8 +81,7 @@ export const load: PageLoad = async ({ parent, url }) => {
'Personal Projects',
resolvedProfile.freeTier,
null,
resolvedProfile.organizationPlatform,
null
resolvedProfile.organizationPlatform
);
} else {
await sdk.forConsole.teams.create({
@@ -55,7 +55,7 @@ export const load = async ({ parent, url, params }) => {
'Personal project',
BillingPlan.FREE,
null,
null
resolvedProfile.organizationPlatform
);
}
+5
View File
@@ -363,6 +363,11 @@
}
}
/* Fix when no vertical scrollbar is present, some environments reserve a gutter by default */
html {
scrollbar-gutter: auto !important;
}
/* TODO: remove this block once Pink V2 is incorporated */
input[type='radio'],
input[type='checkbox']:not([class='switch']),