template
This commit is contained in:
parent
94cd48d1ac
commit
6b6b929fea
2 changed files with 125 additions and 75 deletions
|
|
@ -2,7 +2,7 @@ import { Loader2 } from 'lucide-react'
|
||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||||
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppBroadcastJob, type WhatsAppTemplateRow } from '../../lib/api'
|
import { waTemplateApi, whatsappApi, type WaManagedTemplate, type WhatsAppAccount, type WhatsAppBroadcastJob } from '../../lib/api'
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
{ code: '91', label: 'India' },
|
{ code: '91', label: 'India' },
|
||||||
|
|
@ -12,6 +12,25 @@ const COUNTRIES = [
|
||||||
{ code: '65', label: 'Singapore' },
|
{ code: '65', label: 'Singapore' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const MEDIA_HEADERS = ['IMAGE', 'VIDEO', 'DOCUMENT']
|
||||||
|
|
||||||
|
function headerKind(template: WaManagedTemplate | undefined) {
|
||||||
|
if (!template) return ''
|
||||||
|
const direct = (template.header_format || '').toUpperCase()
|
||||||
|
if (MEDIA_HEADERS.includes(direct)) return direct
|
||||||
|
const drafted = (template.definition?.header?.type || '').toUpperCase()
|
||||||
|
if (MEDIA_HEADERS.includes(drafted)) return drafted
|
||||||
|
const components = Array.isArray(template.components) ? template.components : []
|
||||||
|
for (const component of components) {
|
||||||
|
if (!component || typeof component !== 'object') continue
|
||||||
|
const row = component as { type?: string; format?: string }
|
||||||
|
if (String(row.type || '').toUpperCase() !== 'HEADER') continue
|
||||||
|
const format = String(row.format || '').toUpperCase()
|
||||||
|
if (MEDIA_HEADERS.includes(format)) return format
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck'
|
const TEAM_TEMPLATE_NAME = 'urmi_team_aircheck'
|
||||||
const TEAM_TEMPLATE_BODY = "Team, the broadcast desk is on air. This is the first note Urmi is sending through Meta's marketing line, and it is written for the people building it. Read it on your phone, then bring it to standup so we can close the test."
|
const TEAM_TEMPLATE_BODY = "Team, the broadcast desk is on air. This is the first note Urmi is sending through Meta's marketing line, and it is written for the people building it. Read it on your phone, then bring it to standup so we can close the test."
|
||||||
|
|
||||||
|
|
@ -31,7 +50,7 @@ export function WhatsAppBroadcastPage() {
|
||||||
const [params] = useSearchParams()
|
const [params] = useSearchParams()
|
||||||
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
|
const [accounts, setAccounts] = useState<WhatsAppAccount[]>([])
|
||||||
const [accountId, setAccountId] = useState('')
|
const [accountId, setAccountId] = useState('')
|
||||||
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
|
const [phoneId, setPhoneId] = useState('')
|
||||||
const [managed, setManaged] = useState<WaManagedTemplate[]>([])
|
const [managed, setManaged] = useState<WaManagedTemplate[]>([])
|
||||||
const [templatesReady, setTemplatesReady] = useState(false)
|
const [templatesReady, setTemplatesReady] = useState(false)
|
||||||
const [useSample, setUseSample] = useState(true)
|
const [useSample, setUseSample] = useState(true)
|
||||||
|
|
@ -62,38 +81,23 @@ export function WhatsAppBroadcastPage() {
|
||||||
.then((rows) => {
|
.then((rows) => {
|
||||||
const ready = rows.filter((row) => row.messaging_status === 'active' && row.phones.some((phone) => phone.status === 'connected'))
|
const ready = rows.filter((row) => row.messaging_status === 'active' && row.phones.some((phone) => phone.status === 'connected'))
|
||||||
setAccounts(ready)
|
setAccounts(ready)
|
||||||
if (ready[0]) setAccountId(ready[0].id)
|
const first = ready.flatMap((account) => account.phones.filter((phone) => phone.status === 'connected').map((phone) => ({ account, phone })))[0]
|
||||||
|
if (first) {
|
||||||
|
setAccountId(first.account.id)
|
||||||
|
setPhoneId(first.phone.phone_number_id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Could not load WhatsApp accounts'))
|
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Could not load WhatsApp accounts'))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountId) return
|
|
||||||
setTemplatesReady(false)
|
setTemplatesReady(false)
|
||||||
whatsappApi.templates(accountId)
|
waTemplateApi.list()
|
||||||
.then((payload) => {
|
|
||||||
const marketing = payload.templates.filter((item) => (item.category || '').toUpperCase() === 'MARKETING')
|
|
||||||
setTemplates(marketing)
|
|
||||||
setTemplatesReady(payload.available)
|
|
||||||
const requested = params.get('template')
|
|
||||||
const requestedLanguage = params.get('language')
|
|
||||||
const approved = marketing.find((item) => item.name === requested) || marketing.find((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name)
|
|
||||||
if (requested) setTemplateName(requested)
|
|
||||||
if (requestedLanguage) setTemplateLanguage(requestedLanguage)
|
|
||||||
if (approved?.name && !requested) {
|
|
||||||
setTemplateName(approved.name)
|
|
||||||
setTemplateLanguage(approved.language || 'en')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setTemplates([])
|
|
||||||
setTemplatesReady(false)
|
|
||||||
})
|
|
||||||
waTemplateApi.list(accountId)
|
|
||||||
.then((payload) => setManaged(payload.templates))
|
.then((payload) => setManaged(payload.templates))
|
||||||
.catch(() => setManaged([]))
|
.catch(() => setManaged([]))
|
||||||
}, [accountId, templateTick])
|
.finally(() => setTemplatesReady(true))
|
||||||
|
}, [templateTick])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountId) return
|
if (!accountId) return
|
||||||
|
|
@ -169,7 +173,7 @@ export function WhatsAppBroadcastPage() {
|
||||||
recipients: numbers,
|
recipients: numbers,
|
||||||
body_parameters: parameters,
|
body_parameters: parameters,
|
||||||
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
|
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
|
||||||
use_sample_header: Boolean(mediaHeader && useSample && savedSample),
|
use_sample_header: sampleOn,
|
||||||
})
|
})
|
||||||
setResult(payload)
|
setResult(payload)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|
@ -179,13 +183,46 @@ export function WhatsAppBroadcastPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const approved = templates.filter((item) => (item.status || '').toUpperCase() === 'APPROVED' && item.name)
|
const numbers = accounts.flatMap((account) => account.phones.filter((phone) => phone.status === 'connected').map((phone) => ({ account, phone })))
|
||||||
const pending = templates.filter((item) => (item.status || '').toUpperCase() !== 'APPROVED' && item.name)
|
const selectedNumber = numbers.find((item) => item.phone.phone_number_id === phoneId) || numbers[0]
|
||||||
const selectedTemplate = managed.find((item) => item.name === templateName && item.language === templateLanguage)
|
const approved = managed.filter((item) => (
|
||||||
|| managed.find((item) => item.name === templateName)
|
item.status === 'APPROVED'
|
||||||
const mediaHeader = ['IMAGE', 'VIDEO', 'DOCUMENT'].includes(selectedTemplate?.header_format || '')
|
&& item.category.toUpperCase() === 'MARKETING'
|
||||||
|
&& Boolean(item.name)
|
||||||
|
&& (!selectedNumber || item.account_id === selectedNumber.account.id)
|
||||||
|
))
|
||||||
|
const pending = managed.filter((item) => item.status !== 'APPROVED' && item.category.toUpperCase() === 'MARKETING' && (!selectedNumber || item.account_id === selectedNumber.account.id))
|
||||||
|
const selectedTemplate = approved.find((item) => item.name === templateName && item.language === templateLanguage)
|
||||||
|
|| approved.find((item) => item.name === templateName)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!templatesReady || !numbers.length) return
|
||||||
|
const current = numbers.find((item) => item.phone.phone_number_id === phoneId) || numbers[0]
|
||||||
|
const hasApproved = managed.some((item) => item.status === 'APPROVED' && item.category.toUpperCase() === 'MARKETING' && item.account_id === current.account.id)
|
||||||
|
if (hasApproved) return
|
||||||
|
const next = numbers.find((item) => managed.some((template) => template.status === 'APPROVED' && template.category.toUpperCase() === 'MARKETING' && template.account_id === item.account.id))
|
||||||
|
if (!next || next.phone.phone_number_id === current.phone.phone_number_id) return
|
||||||
|
setPhoneId(next.phone.phone_number_id)
|
||||||
|
setAccountId(next.account.id)
|
||||||
|
}, [templatesReady, managed, numbers, phoneId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!approved.length) return
|
||||||
|
const requested = params.get('template')
|
||||||
|
const requestedLanguage = params.get('language')
|
||||||
|
const current = approved.find((item) => item.name === templateName && item.language === templateLanguage)
|
||||||
|
const requestedMatch = approved.find((item) => item.name === requested && (!requestedLanguage || item.language === requestedLanguage))
|
||||||
|
const next = current || requestedMatch || approved[0]
|
||||||
|
if (next.name !== templateName || next.language !== templateLanguage) {
|
||||||
|
setTemplateName(next.name)
|
||||||
|
setTemplateLanguage(next.language)
|
||||||
|
}
|
||||||
|
}, [approved, params, templateLanguage, templateName])
|
||||||
|
const headerFormat = headerKind(selectedTemplate) || (selectedTemplate?.sample_stored ? 'IMAGE' : '')
|
||||||
|
const mediaHeader = MEDIA_HEADERS.includes(headerFormat)
|
||||||
const savedSample = Boolean(selectedTemplate?.sample_stored)
|
const savedSample = Boolean(selectedTemplate?.sample_stored)
|
||||||
const headerLabel = selectedTemplate?.header_format === 'VIDEO' ? 'video' : selectedTemplate?.header_format === 'DOCUMENT' ? 'document' : 'image'
|
const headerLabel = headerFormat === 'VIDEO' ? 'video' : headerFormat === 'DOCUMENT' ? 'document' : 'image'
|
||||||
|
const sampleOn = Boolean(mediaHeader && useSample && savedSample)
|
||||||
|
|
||||||
function downloadSampleCsv() {
|
function downloadSampleCsv() {
|
||||||
const blob = new Blob(['phone_number\n'], { type: 'text/csv;charset=utf-8' })
|
const blob = new Blob(['phone_number\n'], { type: 'text/csv;charset=utf-8' })
|
||||||
|
|
@ -223,7 +260,7 @@ export function WhatsAppBroadcastPage() {
|
||||||
template_name: templateName.trim(),
|
template_name: templateName.trim(),
|
||||||
template_language: templateLanguage.trim() || 'en',
|
template_language: templateLanguage.trim() || 'en',
|
||||||
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
|
product_policy: strict ? 'STRICT' : 'CLOUD_API_FALLBACK',
|
||||||
use_sample_header: Boolean(mediaHeader && useSample && savedSample),
|
use_sample_header: sampleOn,
|
||||||
body_parameters: bodyValues,
|
body_parameters: bodyValues,
|
||||||
})
|
})
|
||||||
setJobs((current) => [job, ...current.filter((item) => item.id !== job.id)])
|
setJobs((current) => [job, ...current.filter((item) => item.id !== job.id)])
|
||||||
|
|
@ -286,12 +323,20 @@ export function WhatsAppBroadcastPage() {
|
||||||
<form className="wa-biz-card" onSubmit={submit}>
|
<form className="wa-biz-card" onSubmit={submit}>
|
||||||
<div className="wa-token-form">
|
<div className="wa-token-form">
|
||||||
<label className="explorer-input">
|
<label className="explorer-input">
|
||||||
<strong>Account</strong>
|
<strong>Number</strong>
|
||||||
<span>The connected number this broadcast is sent from.</span>
|
<span>Templates for this WhatsApp number. The same templates are shared by every number on that account.</span>
|
||||||
<select value={accountId} onChange={(event) => setAccountId(event.target.value)}>
|
<select
|
||||||
{accounts.map((account) => (
|
value={selectedNumber?.phone.phone_number_id || ''}
|
||||||
<option key={account.id} value={account.id}>
|
onChange={(event) => {
|
||||||
{account.name} · {account.primary_phone?.display_phone || account.phones[0]?.display_phone || 'WhatsApp'}
|
const next = numbers.find((item) => item.phone.phone_number_id === event.target.value)
|
||||||
|
if (!next) return
|
||||||
|
setPhoneId(next.phone.phone_number_id)
|
||||||
|
setAccountId(next.account.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{numbers.map(({ account, phone }) => (
|
||||||
|
<option key={phone.phone_number_id} value={phone.phone_number_id}>
|
||||||
|
{phone.display_phone || phone.verified_name || 'WhatsApp'} · {account.business_name || account.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -299,27 +344,28 @@ export function WhatsAppBroadcastPage() {
|
||||||
<label className="explorer-input">
|
<label className="explorer-input">
|
||||||
<strong>Marketing template</strong>
|
<strong>Marketing template</strong>
|
||||||
<span>
|
<span>
|
||||||
{templatesReady
|
{!templatesReady
|
||||||
? approved.length
|
? 'Loading templates…'
|
||||||
? 'Approved marketing templates on this account.'
|
: approved.length
|
||||||
|
? `Approved marketing templates on ${selectedNumber?.phone.display_phone || 'this number'}.`
|
||||||
: pending.length
|
: pending.length
|
||||||
? `${pending.map((item) => item.name).join(', ')} is waiting for Meta’s approval.`
|
? `${pending.map((item) => item.name).join(', ')} is waiting for Meta’s approval.`
|
||||||
: 'No approved marketing template yet. Submit the team message above, then come back when Meta approves it.'
|
: 'No approved marketing template on this number yet.'}
|
||||||
: 'Template list is unavailable. You can still type the approved template name.'}
|
|
||||||
</span>
|
</span>
|
||||||
{approved.length > 0 ? (
|
{approved.length > 0 ? (
|
||||||
<select
|
<select
|
||||||
value={templateName}
|
value={`${templateName}::${templateLanguage}`}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const next = approved.find((item) => item.name === event.target.value)
|
const next = approved.find((item) => `${item.name}::${item.language}` === event.target.value)
|
||||||
setTemplateName(event.target.value)
|
if (!next) return
|
||||||
if (next?.language) setTemplateLanguage(next.language)
|
setTemplateName(next.name)
|
||||||
|
setTemplateLanguage(next.language)
|
||||||
setUseSample(true)
|
setUseSample(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{approved.map((item) => (
|
{approved.map((item) => (
|
||||||
<option key={`${item.name}-${item.language}`} value={item.name || ''}>
|
<option key={`${item.name}-${item.language}`} value={`${item.name}::${item.language}`}>
|
||||||
{item.name} ({item.language || 'en'})
|
{item.name} ({item.language})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -332,6 +378,23 @@ export function WhatsAppBroadcastPage() {
|
||||||
<span>Use the language code Meta shows on the template, such as en.</span>
|
<span>Use the language code Meta shows on the template, such as en.</span>
|
||||||
<input value={templateLanguage} onChange={(event) => setTemplateLanguage(event.target.value)} required />
|
<input value={templateLanguage} onChange={(event) => setTemplateLanguage(event.target.value)} required />
|
||||||
</label>
|
</label>
|
||||||
|
{mediaHeader && (
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Use existing {headerLabel}</strong>
|
||||||
|
<span>
|
||||||
|
{savedSample
|
||||||
|
? `Send the ${headerLabel} already saved on this template (${selectedTemplate?.sample_name || 'sample file'}). Meta does not keep the approval file, so each send attaches this copy. This applies to the typed numbers and the CSV.`
|
||||||
|
: `This template needs a ${headerLabel} on every send, and none is saved yet.`}
|
||||||
|
</span>
|
||||||
|
{savedSample ? (
|
||||||
|
<input type="checkbox" checked={useSample} onChange={(event) => setUseSample(event.target.checked)} />
|
||||||
|
) : selectedTemplate ? (
|
||||||
|
<Link to={`/console/whatsapp/templates/${selectedTemplate.id}`}>Add the sample on the template</Link>
|
||||||
|
) : (
|
||||||
|
<span>Open the template and add the sample file first.</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="explorer-input">
|
<label className="explorer-input">
|
||||||
<strong>Recipients</strong>
|
<strong>Recipients</strong>
|
||||||
<span>Type numbers here, up to 20, or upload a CSV below. One number per line.</span>
|
<span>Type numbers here, up to 20, or upload a CSV below. One number per line.</span>
|
||||||
|
|
@ -357,27 +420,6 @@ export function WhatsAppBroadcastPage() {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="text-button" type="button" onClick={downloadSampleCsv}>Download a blank sample CSV</button>
|
<button className="text-button" type="button" onClick={downloadSampleCsv}>Download a blank sample CSV</button>
|
||||||
<button className="secondary-button" type="button" disabled={csvBusy || !accountId || !csvFile} onClick={() => void startCsv()}>
|
|
||||||
{csvBusy ? <Loader2 size={16} className="wa-spin" /> : null}
|
|
||||||
{csvFile ? `Start ${csvFile.name}` : 'Start CSV broadcast'}
|
|
||||||
</button>
|
|
||||||
{mediaHeader && (
|
|
||||||
<label className="explorer-input">
|
|
||||||
<strong>Header {headerLabel}</strong>
|
|
||||||
<span>
|
|
||||||
{savedSample
|
|
||||||
? `Use the ${headerLabel} saved on this template (${selectedTemplate?.sample_name || 'sample file'}). Meta does not keep the approval file, so each send has to attach it.`
|
|
||||||
: `This template needs a ${headerLabel} on every send, and none is saved yet.`}
|
|
||||||
</span>
|
|
||||||
{savedSample ? (
|
|
||||||
<input type="checkbox" checked={useSample} onChange={(event) => setUseSample(event.target.checked)} />
|
|
||||||
) : selectedTemplate ? (
|
|
||||||
<Link to={`/console/whatsapp/templates/${selectedTemplate.id}`}>Add the sample on the template</Link>
|
|
||||||
) : (
|
|
||||||
<span>Open the template and add the sample file first.</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="explorer-input">
|
<label className="explorer-input">
|
||||||
<strong>Body values</strong>
|
<strong>Body values</strong>
|
||||||
<span>Leave this empty for the team aircheck. If a template has {'{{1}}'}, {'{{2}}'}, put one value per line in that order.</span>
|
<span>Leave this empty for the team aircheck. If a template has {'{{1}}'}, {'{{2}}'}, put one value per line in that order.</span>
|
||||||
|
|
@ -388,10 +430,16 @@ export function WhatsAppBroadcastPage() {
|
||||||
<span>Leave this on to test Meta’s marketing API. Turn it off only if Meta says this account is not onboarded yet and you still want the message delivered.</span>
|
<span>Leave this on to test Meta’s marketing API. Turn it off only if Meta says this account is not onboarded yet and you still want the message delivered.</span>
|
||||||
<input type="checkbox" checked={strict} onChange={(event) => setStrict(event.target.checked)} />
|
<input type="checkbox" checked={strict} onChange={(event) => setStrict(event.target.checked)} />
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" type="submit" disabled={busy || !accountId}>
|
<div className="wa-broadcast-actions">
|
||||||
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
<button className="primary-button" type="submit" disabled={busy || !accountId}>
|
||||||
Send broadcast
|
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||||
</button>
|
Send broadcast
|
||||||
|
</button>
|
||||||
|
<button className="secondary-button" type="button" disabled={csvBusy || !accountId || !csvFile} onClick={() => void startCsv()}>
|
||||||
|
{csvBusy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||||
|
{csvFile ? `Start ${csvFile.name}` : 'Start CSV broadcast'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1848,6 +1848,8 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
||||||
.wa-token-embed h2 { margin-top: 0; }
|
.wa-token-embed h2 { margin-top: 0; }
|
||||||
.wa-token-form { display: grid; gap: 12px; margin-top: 16px; }
|
.wa-token-form { display: grid; gap: 12px; margin-top: 16px; }
|
||||||
.wa-token-form .primary-button { justify-self: start; display: inline-flex; align-items: center; gap: 8px; }
|
.wa-token-form .primary-button { justify-self: start; display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.wa-broadcast-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-self: start; }
|
||||||
|
.wa-broadcast-actions .primary-button, .wa-broadcast-actions .secondary-button { justify-self: auto; }
|
||||||
.wa-token-page { max-width: 720px; }
|
.wa-token-page { max-width: 720px; }
|
||||||
.wa-broadcast-steps { margin: 0; padding-left: 18px; display: grid; gap: 6px; }
|
.wa-broadcast-steps { margin: 0; padding-left: 18px; display: grid; gap: 6px; }
|
||||||
.wa-broadcast-results { display: grid; gap: 8px; margin-top: 12px; }
|
.wa-broadcast-results { display: grid; gap: 8px; margin-top: 12px; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue