mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 04:39:41 +02:00
feat(credits): add sync billing — monthly credit subscription for cloud sync
Cloud Sync is now a paid feature: 30 credits/month (90/quarter, 360/year).
Users start in local-only mode and opt-in via Settings > Cloud Sync.
1 Credit = 1 Cent, so sync costs ~0.30€/month.
When credits run out, sync is paused (not deleted) and an in-app banner
prompts the user to top up. Local data is always preserved.
Backend (mana-credits):
- New sync_subscriptions table in credits schema
- SyncBillingService with activate/deactivate/chargeRecurring
- User-facing routes: GET/POST /api/v1/sync/{status,activate,deactivate,change-interval}
- Internal routes for server-side checks and cron triggers
Frontend (mana web):
- Sync API client + reactive sync-billing store
- syncEnabled parameter gates createUnifiedSync() — sync only starts when active
- Settings sync page with interval selection and activate/deactivate
- Pause banner in app layout when credits insufficient
Also: removed CALDAV_SYNC/GOOGLE_SYNC operations (not needed),
updated CLOUD_SYNC cost from 5 to 30 credits/month.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9b6720d15
commit
5c2ea614cd
16 changed files with 1082 additions and 29 deletions
76
apps/mana/apps/web/src/lib/api/sync.ts
Normal file
76
apps/mana/apps/web/src/lib/api/sync.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Sync Billing Service for Mana Web App
|
||||
* Handles sync subscription status, activation, and deactivation
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getManaAuthUrl } from './config';
|
||||
|
||||
// Types
|
||||
export type BillingInterval = 'monthly' | 'quarterly' | 'yearly';
|
||||
|
||||
export interface SyncStatus {
|
||||
active: boolean;
|
||||
interval: BillingInterval;
|
||||
nextChargeAt: string | null;
|
||||
pausedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SyncActivateResponse {
|
||||
success: boolean;
|
||||
active: boolean;
|
||||
interval: BillingInterval;
|
||||
nextChargeAt: string;
|
||||
amountCharged: number;
|
||||
}
|
||||
|
||||
// Helper
|
||||
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const response = await fetch(`${getManaAuthUrl()}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Sync Service
|
||||
export const syncService = {
|
||||
async getSyncStatus(): Promise<SyncStatus> {
|
||||
return fetchWithAuth<SyncStatus>('/api/v1/sync/status');
|
||||
},
|
||||
|
||||
async activateSync(interval: BillingInterval = 'monthly'): Promise<SyncActivateResponse> {
|
||||
return fetchWithAuth<SyncActivateResponse>('/api/v1/sync/activate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ interval }),
|
||||
});
|
||||
},
|
||||
|
||||
async deactivateSync(): Promise<{ success: boolean }> {
|
||||
return fetchWithAuth<{ success: boolean }>('/api/v1/sync/deactivate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
},
|
||||
|
||||
async changeInterval(
|
||||
interval: BillingInterval
|
||||
): Promise<{ success: boolean; interval: BillingInterval; amountCharged: number }> {
|
||||
return fetchWithAuth('/api/v1/sync/change-interval', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ interval }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -529,7 +529,11 @@ const EAGER_APPS = new Set([
|
|||
]);
|
||||
// ─── Unified Sync Manager ─────────────────────────────────────
|
||||
|
||||
export function createUnifiedSync(serverUrl: string, getToken: () => Promise<string | null>) {
|
||||
export function createUnifiedSync(
|
||||
serverUrl: string,
|
||||
getToken: () => Promise<string | null>,
|
||||
syncEnabled = true
|
||||
) {
|
||||
const channels = new Map<string, SyncChannelState>();
|
||||
const clientId = getOrCreateClientId();
|
||||
let status: SyncStatus = 'idle';
|
||||
|
|
@ -540,6 +544,8 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
|
|||
// ─── Lifecycle ──────────────────────────────────────────
|
||||
|
||||
function startAll(): void {
|
||||
if (!syncEnabled) return;
|
||||
|
||||
// Register all channels
|
||||
for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
||||
const channel: SyncChannelState = {
|
||||
|
|
@ -1058,6 +1064,7 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
|
|||
* If already synced (has pullTimer), this is a no-op.
|
||||
*/
|
||||
function ensureAppSynced(appId: string): void {
|
||||
if (!syncEnabled) return;
|
||||
const channel = channels.get(appId);
|
||||
if (!channel || channel.pullTimer) return;
|
||||
|
||||
|
|
|
|||
68
apps/mana/apps/web/src/lib/stores/sync-billing.svelte.ts
Normal file
68
apps/mana/apps/web/src/lib/stores/sync-billing.svelte.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Sync Billing Store — tracks sync subscription state
|
||||
*/
|
||||
|
||||
import { syncService, type BillingInterval } from '$lib/api/sync';
|
||||
|
||||
let active = $state(false);
|
||||
let interval = $state<BillingInterval>('monthly');
|
||||
let nextChargeAt = $state<string | null>(null);
|
||||
let paused = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
export const syncBilling = {
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get interval() {
|
||||
return interval;
|
||||
},
|
||||
get nextChargeAt() {
|
||||
return nextChargeAt;
|
||||
},
|
||||
get paused() {
|
||||
return paused;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
|
||||
async load() {
|
||||
loading = true;
|
||||
try {
|
||||
const status = await syncService.getSyncStatus();
|
||||
active = status.active;
|
||||
interval = status.interval;
|
||||
nextChargeAt = status.nextChargeAt;
|
||||
paused = status.pausedAt !== null && !status.active;
|
||||
} catch (e) {
|
||||
console.error('[sync-billing] Failed to load status:', e);
|
||||
// Default to inactive on error
|
||||
active = false;
|
||||
paused = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async activate(billingInterval: BillingInterval = 'monthly') {
|
||||
const result = await syncService.activateSync(billingInterval);
|
||||
active = result.active;
|
||||
interval = result.interval;
|
||||
nextChargeAt = result.nextChargeAt;
|
||||
paused = false;
|
||||
return result;
|
||||
},
|
||||
|
||||
async deactivate() {
|
||||
await syncService.deactivateSync();
|
||||
active = false;
|
||||
nextChargeAt = null;
|
||||
paused = false;
|
||||
},
|
||||
|
||||
async changeInterval(newInterval: BillingInterval) {
|
||||
const result = await syncService.changeInterval(newInterval);
|
||||
interval = result.interval;
|
||||
},
|
||||
};
|
||||
|
|
@ -47,6 +47,7 @@
|
|||
stopMemoroLlmWatcher,
|
||||
} from '$lib/modules/memoro/llm-watcher.svelte';
|
||||
import { createUnifiedSync } from '$lib/data/sync';
|
||||
import { syncBilling } from '$lib/stores/sync-billing.svelte';
|
||||
import { networkStore } from '$lib/stores/network.svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { dashboardStore } from '$lib/stores/dashboard.svelte';
|
||||
|
|
@ -447,8 +448,9 @@
|
|||
if (authStore.isAuthenticated) {
|
||||
setErrorTrackingUser({ id: authStore.user?.id ?? 'unknown', email: authStore.user?.email });
|
||||
trackReturnVisit();
|
||||
await syncBilling.load();
|
||||
const getToken = () => authStore.getValidToken();
|
||||
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken);
|
||||
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active);
|
||||
// Expose on window for SYNC_DEBUG.md (Schritt C). Not a security
|
||||
// concern: every method on the returned object is also reachable
|
||||
// via Dexie + a fresh fetch from the same DevTools console, and
|
||||
|
|
@ -618,6 +620,25 @@
|
|||
<EncryptionIntroBanner />
|
||||
</div>
|
||||
|
||||
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
|
||||
{#if syncBilling.paused}
|
||||
<div class="bottom-stack-notification">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<span>Cloud Sync pausiert — Credits reichen nicht aus.</span>
|
||||
<div class="flex gap-2">
|
||||
<a href="/credits?tab=packages" class="font-medium underline hover:no-underline">
|
||||
Credits aufladen
|
||||
</a>
|
||||
<a href="/settings/sync" class="font-medium underline hover:no-underline">
|
||||
Sync-Einstellungen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Guest notifications — combines the time-based nudge from
|
||||
createGuestMode (one-shot after N minutes) with the
|
||||
event-driven prompts pushed by guestPrompt.requireAccount
|
||||
|
|
|
|||
|
|
@ -170,6 +170,30 @@
|
|||
<!-- Global Settings Section (synced across all apps) -->
|
||||
<GlobalSettingsSection {userSettings} appId="mana" />
|
||||
|
||||
<!-- Cloud Sync Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<span class="text-lg">☁️</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Cloud Sync</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Synchronisiere deine Daten über alle Geräte
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/settings/sync" class="text-sm text-primary hover:underline">
|
||||
Einstellungen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Credits Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
|
|
|
|||
291
apps/mana/apps/web/src/routes/(app)/settings/sync/+page.svelte
Normal file
291
apps/mana/apps/web/src/routes/(app)/settings/sync/+page.svelte
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
<script lang="ts">
|
||||
import { Card, PageHeader } from '@mana/shared-ui';
|
||||
import { syncBilling } from '$lib/stores/sync-billing.svelte';
|
||||
import { creditsService, type CreditBalance } from '$lib/api/credits';
|
||||
import type { BillingInterval } from '$lib/api/sync';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const SYNC_PRICES: Record<BillingInterval, number> = {
|
||||
monthly: 30,
|
||||
quarterly: 90,
|
||||
yearly: 360,
|
||||
};
|
||||
|
||||
const INTERVAL_LABELS: Record<BillingInterval, string> = {
|
||||
monthly: 'Monatlich',
|
||||
quarterly: 'Quartalsweise',
|
||||
yearly: 'Jährlich',
|
||||
};
|
||||
|
||||
let balance = $state<CreditBalance | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedInterval = $state<BillingInterval>('monthly');
|
||||
|
||||
// Toast
|
||||
let toastMessage = $state<string | null>(null);
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([syncBilling.load(), loadBalance()]);
|
||||
selectedInterval = syncBilling.interval;
|
||||
});
|
||||
|
||||
async function loadBalance() {
|
||||
try {
|
||||
balance = await creditsService.getBalance();
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
await syncBilling.activate(selectedInterval);
|
||||
await loadBalance();
|
||||
showToast('Cloud Sync aktiviert!', 'success');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Aktivierung fehlgeschlagen';
|
||||
showToast(error, 'error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeactivate() {
|
||||
if (!confirm('Cloud Sync wirklich deaktivieren? Deine Daten bleiben lokal erhalten.')) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
await syncBilling.deactivate();
|
||||
showToast('Cloud Sync deaktiviert', 'success');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Deaktivierung fehlgeschlagen';
|
||||
showToast(error, 'error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangeInterval() {
|
||||
if (selectedInterval === syncBilling.interval) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
await syncBilling.changeInterval(selectedInterval);
|
||||
showToast(`Intervall auf ${INTERVAL_LABELS[selectedInterval]} geändert`, 'success');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Änderung fehlgeschlagen';
|
||||
showToast(error, 'error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCredits(amount: number): string {
|
||||
return amount.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(message: string, type: 'success' | 'error') {
|
||||
toastMessage = message;
|
||||
toastType = type;
|
||||
setTimeout(() => {
|
||||
toastMessage = null;
|
||||
}, 4000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Cloud Sync"
|
||||
description="Synchronisiere deine Daten über alle Geräte"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{#if syncBilling.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Status Card -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Status</h2>
|
||||
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full {syncBilling.active
|
||||
? 'bg-green-100 dark:bg-green-900/20'
|
||||
: syncBilling.paused
|
||||
? 'bg-amber-100 dark:bg-amber-900/20'
|
||||
: 'bg-surface'}"
|
||||
>
|
||||
<span class="text-2xl"
|
||||
>{syncBilling.active ? '🔄' : syncBilling.paused ? '⏸️' : '☁️'}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold">
|
||||
{#if syncBilling.active}
|
||||
Aktiv
|
||||
{:else if syncBilling.paused}
|
||||
Pausiert
|
||||
{:else}
|
||||
Inaktiv
|
||||
{/if}
|
||||
</p>
|
||||
{#if syncBilling.active && syncBilling.nextChargeAt}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Nächste Abbuchung: {formatDate(syncBilling.nextChargeAt)}
|
||||
</p>
|
||||
{:else if syncBilling.paused}
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400">
|
||||
Credits reichen nicht aus — lade Credits auf um fortzufahren
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Deine Daten sind nur lokal auf diesem Gerät gespeichert
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if balance}
|
||||
<div class="rounded-lg bg-surface p-4 mb-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-muted-foreground">Verfügbare Credits</span>
|
||||
<span class="text-lg font-bold text-primary">{formatCredits(balance.balance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<p class="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if syncBilling.active}
|
||||
<button
|
||||
onclick={handleDeactivate}
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg border border-red-300 dark:border-red-700 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Wird deaktiviert...' : 'Cloud Sync deaktivieren'}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleActivate}
|
||||
disabled={loading ||
|
||||
(balance !== null && balance.balance < SYNC_PRICES[selectedInterval])}
|
||||
class="w-full rounded-lg bg-primary py-3 font-semibold text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{#if loading}
|
||||
Wird aktiviert...
|
||||
{:else}
|
||||
Cloud Sync aktivieren ({SYNC_PRICES[selectedInterval]} Credits)
|
||||
{/if}
|
||||
</button>
|
||||
{#if balance !== null && balance.balance < SYNC_PRICES[selectedInterval]}
|
||||
<p class="mt-2 text-center text-sm text-amber-600 dark:text-amber-400">
|
||||
Nicht genügend Credits.
|
||||
<a href="/credits?tab=packages" class="underline hover:no-underline">Aufladen</a>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Interval Selection Card -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Abrechnungsintervall</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each ['monthly', 'quarterly', 'yearly'] as const as iv}
|
||||
<label
|
||||
class="flex items-center justify-between rounded-lg border p-4 cursor-pointer transition-colors {selectedInterval ===
|
||||
iv
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-surface'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="interval"
|
||||
value={iv}
|
||||
checked={selectedInterval === iv}
|
||||
onchange={() => (selectedInterval = iv)}
|
||||
class="h-4 w-4 text-primary"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">{INTERVAL_LABELS[iv]}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{iv === 'monthly'
|
||||
? '~1 Credit/Tag'
|
||||
: iv === 'quarterly'
|
||||
? '3 Monate'
|
||||
: '12 Monate'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-lg font-bold text-primary">{SYNC_PRICES[iv]}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if syncBilling.active && selectedInterval !== syncBilling.interval}
|
||||
<button
|
||||
onclick={handleChangeInterval}
|
||||
disabled={loading}
|
||||
class="mt-4 w-full rounded-lg bg-surface py-2 font-medium text-foreground hover:bg-surface-hover border border-border transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Wird geändert...' : `Auf ${INTERVAL_LABELS[selectedInterval]} wechseln`}
|
||||
</button>
|
||||
<p class="mt-2 text-center text-xs text-muted-foreground">
|
||||
Änderung gilt ab der nächsten Abbuchung
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-950/30 p-4">
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
Cloud Sync synchronisiert deine Daten verschlüsselt über alle Geräte. Deine lokalen
|
||||
Daten bleiben immer erhalten — auch wenn Sync pausiert oder deaktiviert wird.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Back link -->
|
||||
<div class="mt-6">
|
||||
<a href="/settings" class="text-sm text-primary hover:underline">
|
||||
← Zurück zu Einstellungen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
{#if toastMessage}
|
||||
<div
|
||||
class="fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg {toastType === 'success'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-red-600 text-white'}"
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
{/if}
|
||||
Loading…
Add table
Add a link
Reference in a new issue