@@ -304,7 +227,12 @@ export function OrganizationProfileFormContent({ getBearer }) {
{addressLocked ? (
-
+
{({ dataProps, focusableElementProps, interactionStates }) => (
-
+
{({ dataProps, focusableElementProps, interactionStates }) => (
-
+
{({ dataProps, focusableElementProps, interactionStates }) => (
-
+
{({ dataProps, focusableElementProps, interactionStates }) => (
-
+
{({ dataProps, focusableElementProps, interactionStates }) => (
) : null}
+ >
+ )
+}
+
+export function OrganizationProfileFormContent({ getBearer }) {
+ const t = useAppTranslation()
+ const { submit, store } = useForm()
+ const formObserver = useFormObserver()
+ const values = formObserver?.values ?? {}
+ const invoiceMatchObserver = useFormObserverKey({
+ formKey: 'invoice_matches_registered',
+ })
+ const [websiteProbe, setWebsiteProbe] = useState('idle')
+ const websiteProbeGenRef = useRef(0)
+
+ const locale =
+ typeof navigator !== 'undefined' && navigator.language
+ ? navigator.language
+ : 'en'
+
+ const countryOptions = useMemo(
+ () => buildOrganizationCountryOptions(locale),
+ [locale],
+ )
+
+ const dialOptions = useMemo(
+ () =>
+ Object.entries(PHONE_DIAL_BY_ISO).map(([iso, dial]) => ({
+ iso,
+ dial,
+ label: `${isoCodeToFlagEmoji(iso)} ${dial}`,
+ })),
+ [],
+ )
+
+ const websiteUrl = values.website_url || ''
+
+ const syncInvoiceFieldsFromCompanyAddress = () => {
+ const v = store.getAllValues()
+ store.setValues(
+ {
+ invoice_care_of: v.care_of ?? '',
+ invoice_nominatimPlaceKey: v.nominatimPlaceKey ?? '',
+ invoice_address: v.address ?? '',
+ invoice_house_number: v.house_number ?? '',
+ invoice_postal_code: v.postal_code ?? '',
+ invoice_city: v.city ?? '',
+ invoice_country: v.country ?? '',
+ },
+ true,
+ )
+ }
+
+ useEffect(() => {
+ websiteProbeGenRef.current += 1
+ setWebsiteProbe('idle')
+ }, [websiteUrl])
+
+ const showInvoiceFields =
+ invoiceMatchObserver?.value === false
+
+ return (
+
)
}
diff --git a/web/src/utils/customerWorkspaceTable.js b/web/src/utils/customerWorkspaceTable.js
index c53cf1c..18a0104 100644
--- a/web/src/utils/customerWorkspaceTable.js
+++ b/web/src/utils/customerWorkspaceTable.js
@@ -1,16 +1,25 @@
-export function buildCustomerWorkspaceTableConfig(data) {
+export function buildCustomerWorkspaceTableConfig(data, options = {}) {
+ const pageSize = options.pageSize ?? 20
return {
data,
manualFiltering: true,
manualPagination: true,
manualSorting: true,
+ enableSorting: false,
+ enableColumnFilters: false,
+ enableColumnPinning: false,
onColumnFiltersChange: () => {},
onPaginationChange: () => {},
onSortingChange: () => {},
pageCount: 1,
+ initialState: {
+ pagination: {
+ pageSize,
+ },
+ },
state: {
columnFilters: [],
- pagination: { pageIndex: 0, pageSize: 20 },
+ pagination: { pageIndex: 0, pageSize },
sorting: [],
},
}
diff --git a/web/src/utils/organizationProfileFormValidators.js b/web/src/utils/organizationProfileFormValidators.js
index 83aefec..6320699 100644
--- a/web/src/utils/organizationProfileFormValidators.js
+++ b/web/src/utils/organizationProfileFormValidators.js
@@ -12,58 +12,30 @@ import {
PHONE_COMBINED_RE,
} from './organizationProfileValidation.js'
-function hasConfirmedAddress(getFormValues) {
+function fieldKey(fieldPrefix, baseName) {
+ return fieldPrefix ? `${fieldPrefix}${baseName}` : baseName
+}
+
+function hasConfirmedAddress(getFormValues, fieldPrefix) {
if (!getFormValues) {
return true
}
- const place = getFormValues().nominatimPlaceKey
+ const place = getFormValues()[fieldKey(fieldPrefix, 'nominatimPlaceKey')]
return Boolean(place && String(place).trim())
}
-export function buildOrganizationProfileFormValidators(validators, t, getFormValues) {
+function addressSectionValidators(validators, t, getFormValues, fieldPrefix, onlyWhen) {
+ const active = () => (onlyWhen ? onlyWhen() : true)
+ const fk = (base) => fieldKey(fieldPrefix, base)
return {
- name: (val) => {
- const s = (val || '').trim()
- return validators.notEmpty(s) ?? validators.length(s, [1, MAX_NAME_LEN])
- },
- email: (val) => {
- const s = (val || '').trim()
- return validators.notEmpty(s) ?? validators.email(s)
- },
- website_url: (val) => {
- const raw = (val || '').trim()
- if (!raw) {
- return undefined
- }
- const normalized = normalizeWebsiteUrl(raw)
- if (!WEB_URL_RE.test(normalized)) {
- return t('validationWebsiteInvalid')
- }
- return undefined
- },
- phone: (val) => {
- const combined = combinePhone(val?.dial, val?.national)
- if (!combined) {
+ [fk('nominatimPlaceKey')]: (val) => {
+ if (!active()) {
return undefined
}
- if (!PHONE_COMBINED_RE.test(combined)) {
- return t('validationPhoneInvalid')
- }
- return undefined
+ return validators.notEmpty(val)
},
- care_of: (val) => {
- const s = (val || '').trim()
- if (!s) {
- return undefined
- }
- if (s.length > MAX_CARE_OF_LEN) {
- return t('validationCareOfTooLong')
- }
- return undefined
- },
- nominatimPlaceKey: (val) => validators.notEmpty(val),
- address: (val) => {
- if (!hasConfirmedAddress(getFormValues)) {
+ [fk('address')]: (val) => {
+ if (!active() || !hasConfirmedAddress(getFormValues, fieldPrefix)) {
return undefined
}
const s = (val || '').trim()
@@ -75,8 +47,8 @@ export function buildOrganizationProfileFormValidators(validators, t, getFormVal
}
return undefined
},
- house_number: (val) => {
- if (!hasConfirmedAddress(getFormValues)) {
+ [fk('house_number')]: (val) => {
+ if (!active() || !hasConfirmedAddress(getFormValues, fieldPrefix)) {
return undefined
}
const s = (val || '').trim()
@@ -89,8 +61,8 @@ export function buildOrganizationProfileFormValidators(validators, t, getFormVal
}
return undefined
},
- postal_code: (val) => {
- if (!hasConfirmedAddress(getFormValues)) {
+ [fk('postal_code')]: (val) => {
+ if (!active() || !hasConfirmedAddress(getFormValues, fieldPrefix)) {
return undefined
}
const s = (val || '').trim()
@@ -103,8 +75,8 @@ export function buildOrganizationProfileFormValidators(validators, t, getFormVal
}
return undefined
},
- city: (val) => {
- if (!hasConfirmedAddress(getFormValues)) {
+ [fk('city')]: (val) => {
+ if (!active() || !hasConfirmedAddress(getFormValues, fieldPrefix)) {
return undefined
}
const s = (val || '').trim()
@@ -116,8 +88,8 @@ export function buildOrganizationProfileFormValidators(validators, t, getFormVal
}
return undefined
},
- country: (val) => {
- if (!hasConfirmedAddress(getFormValues)) {
+ [fk('country')]: (val) => {
+ if (!active() || !hasConfirmedAddress(getFormValues, fieldPrefix)) {
return undefined
}
const empty = validators.notEmpty(val)
@@ -129,5 +101,63 @@ export function buildOrganizationProfileFormValidators(validators, t, getFormVal
}
return undefined
},
+ [fk('care_of')]: (val) => {
+ if (!active()) {
+ return undefined
+ }
+ const s = (val || '').trim()
+ if (!s) {
+ return undefined
+ }
+ if (s.length > MAX_CARE_OF_LEN) {
+ return t('validationCareOfTooLong')
+ }
+ return undefined
+ },
+ }
+}
+
+export function buildOrganizationProfileFormValidators(validators, t, getFormValues) {
+ const invoiceOnlyWhen = () =>
+ getFormValues().invoice_matches_registered === false
+
+ return {
+ name: (val) => {
+ const s = (val || '').trim()
+ return validators.notEmpty(s) ?? validators.length(s, [1, MAX_NAME_LEN])
+ },
+ email: (val) => {
+ const s = (val || '').trim()
+ return validators.notEmpty(s) ?? validators.email(s)
+ },
+ website_url: (val) => {
+ const raw = (val || '').trim()
+ if (!raw) {
+ return undefined
+ }
+ const normalized = normalizeWebsiteUrl(raw)
+ if (!WEB_URL_RE.test(normalized)) {
+ return t('validationWebsiteInvalid')
+ }
+ return undefined
+ },
+ phone: (val) => {
+ const combined = combinePhone(val?.dial, val?.national)
+ if (!combined) {
+ return undefined
+ }
+ if (!PHONE_COMBINED_RE.test(combined)) {
+ return t('validationPhoneInvalid')
+ }
+ return undefined
+ },
+ ...addressSectionValidators(validators, t, getFormValues, '', undefined),
+ ...addressSectionValidators(
+ validators,
+ t,
+ getFormValues,
+ 'invoice_',
+ invoiceOnlyWhen,
+ ),
}
}
diff --git a/web/src/utils/organizationProfilePayload.js b/web/src/utils/organizationProfilePayload.js
index 5fd57bf..2d8b82a 100644
--- a/web/src/utils/organizationProfilePayload.js
+++ b/web/src/utils/organizationProfilePayload.js
@@ -1,20 +1,35 @@
import { countryEnglishNameFromIso } from './organizationProfileConstants.js'
import { combinePhone, normalizeWebsiteUrl } from './organizationProfileValidation.js'
+function trimAddressPayload(values, prefix) {
+ const p = prefix || ''
+ const careKey = `${p}care_of`
+ const careOf = (values[careKey] || '').trim()
+ return {
+ address: (values[`${p}address`] || '').trim(),
+ house_number: (values[`${p}house_number`] || '').trim(),
+ postal_code: (values[`${p}postal_code`] || '').trim(),
+ city: (values[`${p}city`] || '').trim(),
+ country: countryEnglishNameFromIso(values[`${p}country`]),
+ care_of: careOf || null,
+ }
+}
+
export function buildCustomerApiPayloadFromFormValues(values) {
const website = normalizeWebsiteUrl(values.website_url || '').trim()
const combinedPhone = combinePhone(values.phone?.dial, values.phone?.national)
- const careOf = (values.care_of || '').trim()
- return {
+ const registered = trimAddressPayload(values, '')
+ const invoiceMatches = values.invoice_matches_registered !== false
+ const payload = {
name: (values.name || '').trim(),
email: (values.email || '').trim(),
- address: (values.address || '').trim(),
- house_number: (values.house_number || '').trim(),
- postal_code: (values.postal_code || '').trim(),
- city: (values.city || '').trim(),
- country: countryEnglishNameFromIso(values.country),
website_url: website || null,
phone_number: combinedPhone || null,
- care_of: careOf || null,
+ registered_address: registered,
+ invoice_matches_registered: invoiceMatches,
+ }
+ if (!invoiceMatches) {
+ payload.invoice_address = trimAddressPayload(values, 'invoice_')
}
+ return payload
}
diff --git a/web/src/utils/organizationProfileValidation.js b/web/src/utils/organizationProfileValidation.js
index 6ef8d1f..6ded7d7 100644
--- a/web/src/utils/organizationProfileValidation.js
+++ b/web/src/utils/organizationProfileValidation.js
@@ -50,24 +50,40 @@ export function splitPhoneNumber(phone) {
export async function probeWebsiteReachable(url) {
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), 8000)
+ const signal = controller.signal
try {
- await fetch(url, {
- method: 'HEAD',
- mode: 'cors',
- signal: controller.signal,
- })
- return true
- } catch {
+ let headOk = false
try {
- await fetch(url, {
- method: 'GET',
+ const head = await fetch(url, {
+ method: 'HEAD',
mode: 'cors',
- signal: controller.signal,
+ signal,
})
+ headOk = head.ok
+ } catch {
+ headOk = false
+ }
+ if (headOk) {
return true
+ }
+ try {
+ const getCors = await fetch(url, {
+ method: 'GET',
+ mode: 'cors',
+ signal,
+ })
+ return getCors.ok
} catch {
- return false
+ await fetch(url, {
+ method: 'GET',
+ mode: 'no-cors',
+ cache: 'no-store',
+ signal,
+ })
+ return true
}
+ } catch {
+ return false
} finally {
window.clearTimeout(timeout)
}
diff --git a/web/templates/listmonk/product-booking-app-config.html b/web/templates/listmonk/product-booking-app-config.html
new file mode 100644
index 0000000..ec5d627
--- /dev/null
+++ b/web/templates/listmonk/product-booking-app-config.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
Booking confirmation
+
+
+
+
+
+
+
+ |
+ Booking confirmation
+ Your products are booked
+ |
+
+
+ |
+
+ Hello{{ if .Tx.Data.recipient_name }} {{ .Tx.Data.recipient_name }}{{ end }},
+
+
+ {{ if .Tx.Data.organization_name }}
+ {{ .Tx.Data.organization_name }} has completed booking for the following products. Below you will also find the application configuration you need to get started.
+ {{ else }}
+ Your booking is confirmed. Below are the booked products and the application configuration you need to get started.
+ {{ end }}
+
+ |
+
+
+
+ Booked products
+
+
+
+ | Product |
+ Plan |
+ Start |
+ Seats |
+
+
+
+ {{ range .Tx.Data.products }}
+
+ | {{ .name }} |
+ {{ .plan }} |
+ {{ .start_date }} |
+ {{ if .seats }}{{ .seats }}{{ else }}—{{ end }} |
+
+ {{ else }}
+
+ | No line items were provided. |
+
+ {{ end }}
+
+
+ |
+
+
+
+ Application configuration
+
+
+ {{ range .Tx.Data.app_config }}
+
+ | {{ .label }} |
+ {{ .value }} |
+
+ {{ else }}
+
+ | Configuration will be available in your customer portal. |
+
+ {{ end }}
+
+
+ |
+
+ {{ if .Tx.Data.notes }}
+
+ |
+
+ Notes:
+ {{ .Tx.Data.notes }}
+
+ |
+
+ {{ end }}
+
+ |
+ {{ if .Tx.Data.portal_url }}
+ Open customer portal
+ {{ end }}
+ |
+
+
+ |
+
+ {{ if .Tx.Data.support_email }}
+ Questions? Contact {{ .Tx.Data.support_email }}.
+ {{ else if .Tx.Data.organization_name }}
+ If you need help, reply to your organization administrator or open the portal above.
+ {{ else }}
+ If you need help, use the customer portal or contact support.
+ {{ end }}
+
+ |
+
+
+ This message was sent automatically. Please do not reply unless you are instructed to.
+ |
+
+
+
+
diff --git a/web/templates/listmonk/product-cancellation-app-config.html b/web/templates/listmonk/product-cancellation-app-config.html
new file mode 100644
index 0000000..82c570a
--- /dev/null
+++ b/web/templates/listmonk/product-cancellation-app-config.html
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
Cancellation confirmation
+
+
+
+
+
+
+
+ |
+ Cancellation
+ Subscription cancellation recorded
+ |
+
+
+ |
+
+ {{ .Tx.Data.organization_name }} — we have recorded the cancellation for
+ {{ .Tx.Data.product_name }}
+ ({{ .Tx.Data.plan_name }}).
+
+ |
+
+
+
+ Deadlines & dates
+
+
+ {{ range .Tx.Data.deadlines }}
+
+ | {{ .label }} |
+ {{ .value }} |
+
+ {{ else }}
+
+ | No deadline rows were provided. |
+
+ {{ end }}
+
+
+ |
+
+
+
+ Invoices
+ {{ if .Tx.Data.has_open_invoices }}
+
+ Open invoices at the time of cancellation: {{ .Tx.Data.open_invoice_count }}.
+ {{ if .Tx.Data.has_future_dated_pending_invoices }}
+ At least one pending invoice has a due date in the future — please pay by the stated date.
+ {{ end }}
+
+
+
+
+ | Title |
+ Date |
+ Amount |
+ Status |
+
+
+
+ {{ range .Tx.Data.open_invoices }}
+
+ | {{ .title }} |
+ {{ .date }} |
+ {{ .amount }} |
+ {{ .status }} |
+
+ {{ end }}
+
+
+ {{ else }}
+ There are no unpaid invoices (pending or overdue) for this booking at cancellation time.
+ {{ end }}
+ {{ if .Tx.Data.has_scheduled_future_billing }}
+
+ A future billing date may still apply for recurring plans:
+ {{ .Tx.Data.next_scheduled_billing_date }}.
+ If you still receive an invoice for a period after cancellation, please check your contract or contact support.
+
+ {{ end }}
+ |
+
+
+
+ Application configuration
+
+
+ {{ range .Tx.Data.app_config }}
+
+ | {{ .label }} |
+ {{ .value }} |
+
+ {{ else }}
+
+ | — |
+
+ {{ end }}
+
+
+ |
+
+
+ |
+ {{ if .Tx.Data.portal_url }}
+ Open customer portal
+ {{ end }}
+ |
+
+
+ |
+ This message was sent automatically after a cancellation was recorded.
+ |
+
+
+ |
+
+
+
+