feat(events): promo code wire types, validate endpoint client, pure helpers
Types follow upstream v2 names (PromoCode.max_uses / used_count,
PromoValidateRequest, BasketDiscount, BasketTotals) as served by the
events extension >= v1.6.1-aio.12; EventExtra.promo_codes becomes
optional because public event responses no longer carry it.
TicketApiService.validatePromoCode posts the upper-cased code + quantity
to POST /events/api/v1/promo/validate/{event_id}; request() now attaches
the HTTP status to thrown errors so a 404 from an older extension can be
told apart from a 400. lib/promoCodes.ts holds the pure helpers shared by
the organizer editor and the buyer dialog (normalise, editor validation
returning i18n keys, wire <-> row mapping, display-only percent rounding).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
This commit is contained in:
parent
9c0abfae1f
commit
0a21c092ff
5 changed files with 332 additions and 2 deletions
93
src/modules/events/lib/promoCodes.spec.ts
Normal file
93
src/modules/events/lib/promoCodes.spec.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
applyPercent,
|
||||||
|
fromWirePromoCodes,
|
||||||
|
hasPromoErrors,
|
||||||
|
isUnsupportedEndpointError,
|
||||||
|
normalizePromoCode,
|
||||||
|
toWirePromoCodes,
|
||||||
|
validatePromoRows,
|
||||||
|
type PromoCodeRow,
|
||||||
|
} from './promoCodes'
|
||||||
|
|
||||||
|
function row(overrides: Partial<PromoCodeRow> = {}): PromoCodeRow {
|
||||||
|
return { code: 'HALF', discount_percent: 50, max_uses: null, active: true, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizePromoCode', () => {
|
||||||
|
it('trims and upper-cases, and tolerates null', () => {
|
||||||
|
expect(normalizePromoCode(' summer24 ')).toBe('SUMMER24')
|
||||||
|
expect(normalizePromoCode(null)).toBe('')
|
||||||
|
expect(normalizePromoCode(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validatePromoRows', () => {
|
||||||
|
it('accepts a well-formed row', () => {
|
||||||
|
const errors = validatePromoRows([row()])
|
||||||
|
expect(errors).toEqual([{}])
|
||||||
|
expect(hasPromoErrors(errors)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags an empty code', () => {
|
||||||
|
expect(validatePromoRows([row({ code: ' ' })])[0].code).toBe('events.create.promo.errors.required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags duplicates case-insensitively on both rows', () => {
|
||||||
|
const errors = validatePromoRows([row({ code: 'half' }), row({ code: ' HALF ' })])
|
||||||
|
expect(errors[0].code).toBe('events.create.promo.errors.duplicate')
|
||||||
|
expect(errors[1].code).toBe('events.create.promo.errors.duplicate')
|
||||||
|
expect(hasPromoErrors(errors)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires the percentage to be within 1..100', () => {
|
||||||
|
for (const pct of [null, 0, 101, Number.NaN]) {
|
||||||
|
expect(validatePromoRows([row({ discount_percent: pct })])[0].discount_percent)
|
||||||
|
.toBe('events.create.promo.errors.percentRange')
|
||||||
|
}
|
||||||
|
expect(validatePromoRows([row({ discount_percent: 100 })])[0].discount_percent).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows blank max uses and rejects zero / fractions', () => {
|
||||||
|
expect(validatePromoRows([row({ max_uses: null })])[0].max_uses).toBeUndefined()
|
||||||
|
expect(validatePromoRows([row({ max_uses: 3 })])[0].max_uses).toBeUndefined()
|
||||||
|
expect(validatePromoRows([row({ max_uses: 0 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin')
|
||||||
|
expect(validatePromoRows([row({ max_uses: 1.5 })])[0].max_uses).toBe('events.create.promo.errors.maxUsesMin')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toWirePromoCodes / fromWirePromoCodes', () => {
|
||||||
|
it('normalises codes, drops used_count and maps blank max uses to null', () => {
|
||||||
|
expect(toWirePromoCodes([row({ code: ' half ', used_count: 4 }), row({ code: 'CAP', max_uses: 2 })])).toEqual([
|
||||||
|
{ code: 'HALF', discount_percent: 50, active: true, max_uses: null },
|
||||||
|
{ code: 'CAP', discount_percent: 50, active: true, max_uses: 2 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips the wire shape and tolerates the older three-field shape', () => {
|
||||||
|
expect(fromWirePromoCodes([{ code: 'OLD', discount_percent: 20, active: false }])).toEqual([
|
||||||
|
{ code: 'OLD', discount_percent: 20, max_uses: null, active: false, used_count: 0 },
|
||||||
|
])
|
||||||
|
expect(fromWirePromoCodes(undefined)).toEqual([])
|
||||||
|
expect(fromWirePromoCodes([{ code: 'CAP', discount_percent: 10, active: true, max_uses: 0, used_count: 2 }]))
|
||||||
|
.toEqual([{ code: 'CAP', discount_percent: 10, max_uses: null, active: true, used_count: 2 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applyPercent', () => {
|
||||||
|
it('rounds sats down to whole units and fiat to two decimals', () => {
|
||||||
|
expect(applyPercent(333, 15, 'sat')).toBe(283)
|
||||||
|
expect(applyPercent(59.97, 33, 'EUR')).toBe(40.18)
|
||||||
|
expect(applyPercent(1000, 100, 'sats')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isUnsupportedEndpointError', () => {
|
||||||
|
it('recognises 404/405 and nothing else', () => {
|
||||||
|
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 404 }))).toBe(true)
|
||||||
|
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 405 }))).toBe(true)
|
||||||
|
expect(isUnsupportedEndpointError(Object.assign(new Error('x'), { status: 400 }))).toBe(false)
|
||||||
|
expect(isUnsupportedEndpointError(new Error('x'))).toBe(false)
|
||||||
|
expect(isUnsupportedEndpointError(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
125
src/modules/events/lib/promoCodes.ts
Normal file
125
src/modules/events/lib/promoCodes.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import type { PromoCode } from '../types/ticket'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promo-code helpers shared by the organizer editor and the buyer
|
||||||
|
* dialog. Pure functions, no Vue — mirror `promo.py` in the events
|
||||||
|
* extension by name so the two sides stay recognisably in step:
|
||||||
|
*
|
||||||
|
* normalizePromoCode ↔ promo.normalize_code
|
||||||
|
* applyPercent ↔ promo.round_amount / basket_totals (display fallback only)
|
||||||
|
*
|
||||||
|
* The server's `POST /events/api/v1/promo/validate/{event_id}` is the
|
||||||
|
* source of truth for the discounted total; `applyPercent` exists so
|
||||||
|
* the UI can still show *something* against an older extension.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One editor row. `discount_percent` / `max_uses` are `null` while the
|
||||||
|
* organizer is still typing (empty inputs), never `0`-as-empty. */
|
||||||
|
export interface PromoCodeRow {
|
||||||
|
code: string
|
||||||
|
discount_percent: number | null
|
||||||
|
/** `null` = unlimited (backend `max_uses: None`). */
|
||||||
|
max_uses: number | null
|
||||||
|
active: boolean
|
||||||
|
/** Derived by the backend from paid tickets; read-only in the editor. */
|
||||||
|
used_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-row validation messages as i18n keys (the component runs them
|
||||||
|
* through `t()`); a missing field means the field is fine. */
|
||||||
|
export interface PromoRowErrors {
|
||||||
|
code?: string
|
||||||
|
discount_percent?: string
|
||||||
|
max_uses?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_KEYS = {
|
||||||
|
required: 'events.create.promo.errors.required',
|
||||||
|
duplicate: 'events.create.promo.errors.duplicate',
|
||||||
|
percentRange: 'events.create.promo.errors.percentRange',
|
||||||
|
maxUsesMin: 'events.create.promo.errors.maxUsesMin',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Trim + upper-case, the same normalisation the backend applies before
|
||||||
|
* matching, so what the buyer sees in the input is what gets matched. */
|
||||||
|
export function normalizePromoCode(raw: string | null | undefined): string {
|
||||||
|
return (raw ?? '').trim().toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor rules — stricter than the backend on purpose: the backend
|
||||||
|
* accepts 0 % (a no-op the buyer would see as "code not valid"), the
|
||||||
|
* editor requires 1–100 so an organizer cannot create a dud by accident.
|
||||||
|
*/
|
||||||
|
export function validatePromoRows(rows: PromoCodeRow[]): PromoRowErrors[] {
|
||||||
|
const seen = new Map<string, number>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const code = normalizePromoCode(row.code)
|
||||||
|
if (code) seen.set(code, (seen.get(code) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return rows.map((row) => {
|
||||||
|
const errors: PromoRowErrors = {}
|
||||||
|
const code = normalizePromoCode(row.code)
|
||||||
|
if (!code) {
|
||||||
|
errors.code = ERROR_KEYS.required
|
||||||
|
} else if ((seen.get(code) ?? 0) > 1) {
|
||||||
|
errors.code = ERROR_KEYS.duplicate
|
||||||
|
}
|
||||||
|
const pct = row.discount_percent
|
||||||
|
if (pct === null || !Number.isFinite(pct) || pct < 1 || pct > 100) {
|
||||||
|
errors.discount_percent = ERROR_KEYS.percentRange
|
||||||
|
}
|
||||||
|
const max = row.max_uses
|
||||||
|
if (max !== null && (!Number.isInteger(max) || max < 1)) {
|
||||||
|
errors.max_uses = ERROR_KEYS.maxUsesMin
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPromoErrors(errors: PromoRowErrors[]): boolean {
|
||||||
|
return errors.some((e) => Object.keys(e).length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editor rows → wire `extra.promo_codes`. Drops the read-only
|
||||||
|
* `used_count` (the backend recomputes it) and blank max-uses → `null`. */
|
||||||
|
export function toWirePromoCodes(rows: PromoCodeRow[]): PromoCode[] {
|
||||||
|
return rows.map((row) => ({
|
||||||
|
code: normalizePromoCode(row.code),
|
||||||
|
discount_percent: row.discount_percent ?? 0,
|
||||||
|
active: row.active,
|
||||||
|
max_uses: row.max_uses && row.max_uses > 0 ? Math.trunc(row.max_uses) : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire `extra.promo_codes` → editor rows. Tolerates the pre-`max_uses`
|
||||||
|
* shape (older extension) and a missing list (public records). */
|
||||||
|
export function fromWirePromoCodes(codes: PromoCode[] | null | undefined): PromoCodeRow[] {
|
||||||
|
return (codes ?? []).map((c) => ({
|
||||||
|
code: c.code,
|
||||||
|
discount_percent: c.discount_percent ?? 0,
|
||||||
|
max_uses: c.max_uses && c.max_uses > 0 ? c.max_uses : null,
|
||||||
|
active: c.active ?? true,
|
||||||
|
used_count: c.used_count ?? 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-only fallback for the discounted total when the validate
|
||||||
|
* endpoint is unavailable. Rounds like the backend: whole sats, two
|
||||||
|
* decimals for fiat.
|
||||||
|
*/
|
||||||
|
export function applyPercent(subtotal: number, percent: number, currency: string): number {
|
||||||
|
const raw = subtotal * (1 - percent / 100)
|
||||||
|
const unit = currency.toLowerCase()
|
||||||
|
if (unit === 'sat' || unit === 'sats') return Math.max(0, Math.trunc(raw))
|
||||||
|
return Math.max(0, Math.round(raw * 100) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An older extension without the validate route answers 404 (or 405
|
||||||
|
* when the path exists for another method). Anything else is a real
|
||||||
|
* error. */
|
||||||
|
export function isUnsupportedEndpointError(err: unknown): boolean {
|
||||||
|
const status = (err as { status?: unknown } | null)?.status
|
||||||
|
return status === 404 || status === 405
|
||||||
|
}
|
||||||
|
|
@ -90,3 +90,57 @@ describe('TicketApiService.getTicket / resendTicketEmail', () => {
|
||||||
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
|
expect(legacy.email).toEqual({ attempted: false, sent: false, error: null })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('TicketApiService promo codes', () => {
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
let api: TicketApiService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
api = new TicketApiService({ baseUrl: BASE, apiKey: 'inst-key' })
|
||||||
|
})
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
function lastCall() {
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||||
|
return { url, init, body: JSON.parse(init.body as string), headers: init.headers as Record<string, string> }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('validatePromoCode posts the v2 shape with an upper-cased code', async () => {
|
||||||
|
const totals = {
|
||||||
|
subtotal: 2000, discount: 1000, total: 1000, currency: 'sat',
|
||||||
|
discounts_applied: [{ code: 'HALF', discount_percent: 50, discount_fixed: null, amount_saved: 1000 }],
|
||||||
|
}
|
||||||
|
fetchMock.mockResolvedValue(okJson(totals))
|
||||||
|
const res = await api.validatePromoCode('evt', ' half ', 2)
|
||||||
|
const { url, init, body } = lastCall()
|
||||||
|
expect(url).toBe(`${BASE}/events/api/v1/promo/validate/evt`)
|
||||||
|
expect(init.method).toBe('POST')
|
||||||
|
expect(body).toEqual({ codes: ['HALF'], quantity: 2 })
|
||||||
|
expect(res).toEqual(totals)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries the HTTP status on errors so a missing route (older extension) is recognisable', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({ detail: 'Not Found' }) } as Response)
|
||||||
|
await expect(api.validatePromoCode('evt', 'HALF')).rejects.toMatchObject({ message: 'Not Found', status: 404 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requestTicket forwards promo_code verbatim', async () => {
|
||||||
|
fetchMock.mockResolvedValue(okJson({ payment_hash: 'h', payment_request: 'lnbc1', is_fiat: false }))
|
||||||
|
await api.requestTicket('evt', { userId: 'u1', promoCode: 'HALF', quantity: 2 })
|
||||||
|
expect(lastCall().body).toEqual({ user_id: 'u1', promo_code: 'HALF', quantity: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createEvent sends extra.promo_codes verbatim', async () => {
|
||||||
|
fetchMock.mockResolvedValue(okJson({ id: 'evt' }))
|
||||||
|
const promo_codes = [{ code: 'HALF', discount_percent: 50, active: true, max_uses: 2 }]
|
||||||
|
await api.createEvent(
|
||||||
|
{ name: 'E', event_start_date: '2030-01-01', extra: { promo_codes } },
|
||||||
|
'inkey',
|
||||||
|
)
|
||||||
|
const { body, headers } = lastCall()
|
||||||
|
expect(body.extra.promo_codes).toEqual(promo_codes)
|
||||||
|
expect(headers['X-API-KEY']).toBe('inkey')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type {
|
import type {
|
||||||
|
BasketTotals,
|
||||||
EventTicket,
|
EventTicket,
|
||||||
EventTicketExtra,
|
EventTicketExtra,
|
||||||
CreateTicketRequest,
|
CreateTicketRequest,
|
||||||
|
|
@ -9,7 +10,9 @@ import type {
|
||||||
TicketResendResult,
|
TicketResendResult,
|
||||||
TicketedEvent,
|
TicketedEvent,
|
||||||
CreateEventRequest,
|
CreateEventRequest,
|
||||||
|
PromoValidateRequest,
|
||||||
} from '../types/ticket'
|
} from '../types/ticket'
|
||||||
|
import { normalizePromoCode } from '../lib/promoCodes'
|
||||||
|
|
||||||
export interface TicketApiConfig {
|
export interface TicketApiConfig {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
|
|
@ -335,6 +338,8 @@ export class TicketApiService {
|
||||||
name?: string | null
|
name?: string | null
|
||||||
registered: boolean
|
registered: boolean
|
||||||
registered_at: string | null
|
registered_at: string | null
|
||||||
|
/** Promo code the buyer redeemed (events ext >= v1.6.1-aio.12). */
|
||||||
|
applied_promo_code?: string | null
|
||||||
}>
|
}>
|
||||||
}> {
|
}> {
|
||||||
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
|
return this.request(`/events/api/v1/tickets/event/${eventId}/stats`, {
|
||||||
|
|
@ -343,6 +348,22 @@ export class TicketApiService {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview what a promo code does to a purchase of `quantity` tickets
|
||||||
|
* (`POST /events/api/v1/promo/validate/{event_id}`, anonymous). The
|
||||||
|
* backend applies the first valid code; an empty `discounts_applied`
|
||||||
|
* means "not valid". Older extensions 404 — the thrown error carries
|
||||||
|
* `status` so callers can fall back to "applied at checkout".
|
||||||
|
*/
|
||||||
|
async validatePromoCode(eventId: string, code: string, quantity = 1): Promise<BasketTotals> {
|
||||||
|
const body: PromoValidateRequest = { codes: [normalizePromoCode(code)], quantity }
|
||||||
|
return this.request(`/events/api/v1/promo/validate/${eventId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a paid ticket as registered at the door. Organizer-only —
|
* Mark a paid ticket as registered at the door. Organizer-only —
|
||||||
* requires the event-owning wallet's admin_key. Backend rejects
|
* requires the event-owning wallet's admin_key. Backend rejects
|
||||||
|
|
@ -441,7 +462,9 @@ export class TicketApiService {
|
||||||
: Array.isArray(error.detail)
|
: Array.isArray(error.detail)
|
||||||
? error.detail[0]?.msg ?? 'Request failed'
|
? error.detail[0]?.msg ?? 'Request failed'
|
||||||
: 'Request failed'
|
: 'Request failed'
|
||||||
throw new Error(errorMessage)
|
// Carry the HTTP status so callers can tell "route missing on an
|
||||||
|
// older extension" (404) apart from "bad input" (400).
|
||||||
|
throw Object.assign(new Error(errorMessage), { status: response.status })
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,38 @@ export interface PromoCode {
|
||||||
code: string
|
code: string
|
||||||
discount_percent: number
|
discount_percent: number
|
||||||
active: boolean
|
active: boolean
|
||||||
|
/** Redemption cap; `null` / absent = unlimited (events ext >= v1.6.1-aio.12,
|
||||||
|
* same name as upstream v2). */
|
||||||
|
max_uses?: number | null
|
||||||
|
/** Paid tickets that used this code — derived by the backend on read,
|
||||||
|
* never sent back. */
|
||||||
|
used_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for `POST /events/api/v1/promo/validate/{event_id}` (upstream
|
||||||
|
* v2 shape; `quantity` stands in for v2's `items` since this fork has
|
||||||
|
* no ticket types). */
|
||||||
|
export interface PromoValidateRequest {
|
||||||
|
codes: string[]
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BasketDiscount {
|
||||||
|
code: string
|
||||||
|
discount_percent: number | null
|
||||||
|
/** Always null on this fork (percent-only); kept for the v2 shape. */
|
||||||
|
discount_fixed: number | null
|
||||||
|
amount_saved: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response of the promo validate endpoint. An empty `discounts_applied`
|
||||||
|
* means none of the submitted codes is valid for this event/quantity. */
|
||||||
|
export interface BasketTotals {
|
||||||
|
subtotal: number
|
||||||
|
discount: number
|
||||||
|
total: number
|
||||||
|
discounts_applied: BasketDiscount[]
|
||||||
|
currency: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,7 +51,10 @@ export interface PromoCode {
|
||||||
* in upstream v1.4.0 (PR #50) and v1.6.0.
|
* in upstream v1.4.0 (PR #50) and v1.6.0.
|
||||||
*/
|
*/
|
||||||
export interface EventExtra {
|
export interface EventExtra {
|
||||||
promo_codes: PromoCode[]
|
/** Present on organizer-authenticated records only: public event
|
||||||
|
* responses (`/events/public`, `/events/{id}`) no longer carry the
|
||||||
|
* code list (events ext >= v1.6.1-aio.12). */
|
||||||
|
promo_codes?: PromoCode[]
|
||||||
conditional: boolean
|
conditional: boolean
|
||||||
min_tickets: number
|
min_tickets: number
|
||||||
email_notifications: boolean
|
email_notifications: boolean
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue