diff --git a/api/.env.docker b/api/.env.docker
index 9c4e66d5..db728ef6 100644
--- a/api/.env.docker
+++ b/api/.env.docker
@@ -34,18 +34,7 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
-PUSHER_APP_ID=
-PUSHER_APP_KEY=
-PUSHER_APP_SECRET=
-PUSHER_APP_CLUSTER=mt1
-
-MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
-MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
-
JWT_TTL=1440
JWT_SECRET=
-MUX_WORKSPACE_ID=
-MUX_API_TOKEN=
-
OPEN_AI_API_KEY=
\ No newline at end of file
diff --git a/api/.env.example b/api/.env.example
index 41365481..61448dee 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -76,9 +76,6 @@ H_CAPTCHA_SECRET_KEY=
RE_CAPTCHA_SITE_KEY=
RE_CAPTCHA_SECRET_KEY=
-MUX_WORKSPACE_ID=
-MUX_API_TOKEN=
-
ADMIN_EMAILS=
TEMPLATE_EDITOR_EMAILS=
diff --git a/api/app/Models/Forms/Form.php b/api/app/Models/Forms/Form.php
index 42b6f2ec..afecd4b2 100644
--- a/api/app/Models/Forms/Form.php
+++ b/api/app/Models/Forms/Form.php
@@ -223,7 +223,7 @@ class Form extends Model implements CachableAttributes
public function getIsClosedAttribute()
{
- return $this->closes_at && now()->gt($this->closes_at);
+ return $this->visibility === 'closed' || ($this->closes_at && now()->gt($this->closes_at));
}
public function getFormPendingSubmissionKeyAttribute()
diff --git a/api/app/Open/MentionParser.php b/api/app/Open/MentionParser.php
index b679e8e3..5e254bda 100644
--- a/api/app/Open/MentionParser.php
+++ b/api/app/Open/MentionParser.php
@@ -4,6 +4,7 @@ namespace App\Open;
use DOMDocument;
use DOMXPath;
+use DOMElement;
class MentionParser
{
@@ -39,21 +40,24 @@ class MentionParser
libxml_use_internal_errors($internalErrors);
$xpath = new DOMXPath($doc);
- $mentionElements = $xpath->query("//span[@mention]");
+
+ $mentionElements = $xpath->query("//span[@mention or @mention='true']");
foreach ($mentionElements as $element) {
- $fieldId = $element->getAttribute('mention-field-id');
- $fallback = $element->getAttribute('mention-fallback');
- $value = $this->getData($fieldId);
+ if ($element instanceof DOMElement) {
+ $fieldId = $element->getAttribute('mention-field-id');
+ $fallback = $element->getAttribute('mention-fallback');
+ $value = $this->getData($fieldId);
- if ($value !== null) {
- $textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value);
- $element->parentNode->replaceChild($textNode, $element);
- } elseif ($fallback) {
- $textNode = $doc->createTextNode($fallback);
- $element->parentNode->replaceChild($textNode, $element);
- } else {
- $element->parentNode->removeChild($element);
+ if ($value !== null) {
+ $textNode = $doc->createTextNode(is_array($value) ? implode($this->urlFriendly ? ',+' : ', ', $value) : $value);
+ $element->parentNode->replaceChild($textNode, $element);
+ } elseif ($fallback) {
+ $textNode = $doc->createTextNode($fallback);
+ $element->parentNode->replaceChild($textNode, $element);
+ } else {
+ $element->parentNode->removeChild($element);
+ }
}
}
diff --git a/api/tests/Unit/Service/Forms/MentionParserTest.php b/api/tests/Unit/Service/Forms/MentionParserTest.php
index 0607e17b..61bdbb58 100644
--- a/api/tests/Unit/Service/Forms/MentionParserTest.php
+++ b/api/tests/Unit/Service/Forms/MentionParserTest.php
@@ -35,6 +35,18 @@ describe('MentionParser', function () {
expect($result)->toBe('
Hello !
');
});
+ it('supports mention="true" syntax', function () {
+ $content = 'Hello Full Name
';
+ $data = [
+ ['id' => '123', 'value' => 'John Doe']
+ ];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello John Doe
');
+ });
+
describe('parseAsText', function () {
it('converts HTML to plain text with proper line breaks', function () {
$content = 'First line
Second line
';
@@ -144,6 +156,29 @@ describe('MentionParser', function () {
expect($result)->toBe('Tags: PHP, Laravel, Testing
');
});
+ test('it supports mention="true" attributes', function () {
+ $content = 'Hello Full Name
';
+ $data = [['id' => '123', 'value' => 'John Doe']];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello John Doe
');
+ });
+
+ test('it handles multiple mentions with mention="true" syntax', function () {
+ $content = 'Name and Title
';
+ $data = [
+ ['id' => '123', 'value' => 'John Doe'],
+ ['id' => '456', 'value' => 'Developer'],
+ ];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('John Doe and Developer
');
+ });
+
test('it preserves HTML structure', function () {
$content = 'Hello Placeholder
How are you?
';
$data = [['id' => '123', 'value' => 'World']];
@@ -174,6 +209,53 @@ describe('MentionParser', function () {
expect($result)->toBe('some text replaced text dewde');
});
+ test('it handles URL-encoded field IDs', function () {
+ $content = 'Hello Full Name
';
+ $data = [['id' => '%3ARGE', 'value' => 'John Doe']];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello John Doe
');
+ });
+
+ test('it handles URL-encoded field IDs with mention="true" syntax', function () {
+ $content = 'Hello Full Name
';
+ $data = [['id' => '%3ARGE', 'value' => 'John Doe']];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello John Doe
');
+ });
+
+ test('it handles multiple mentions with URL-encoded IDs and mention="true" syntax', function () {
+ $content = 'Full Name and Phone
';
+ $data = [
+ ['id' => '%3ARGE', 'value' => 'John Doe'],
+ ['id' => 'V%7D%40S', 'value' => '123-456-7890'],
+ ];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('John Doe and 123-456-7890
');
+ });
+
+ test('it recreates real-world example with URL-encoded IDs', function () {
+ $content = 'Hello there 👋
This is a confirmation that your submission was successfully saved.
Full NameContact FormPhone Number
';
+ $data = [
+ ['id' => '%3ARGE', 'value' => 'jujujujuju'],
+ ['id' => 'title', 'value' => 'jujuuj'],
+ ['id' => 'V%7D%40S', 'value' => '555-1234'],
+ ];
+
+ $parser = new MentionParser($content, $data);
+ $result = $parser->parse();
+
+ expect($result)->toBe('Hello there 👋
This is a confirmation that your submission was successfully saved.
jujujujujujujuuj555-1234
');
+ });
+
describe('urlFriendlyOutput', function () {
test('it encodes special characters in values', function () {
$content = 'Test: Placeholder
';
diff --git a/client/components/forms/MentionInput.vue b/client/components/forms/MentionInput.vue
index 287b4708..f14ad612 100644
--- a/client/components/forms/MentionInput.vue
+++ b/client/components/forms/MentionInput.vue
@@ -3,12 +3,12 @@
-
+
-
+
-
+
-
+
-
-
-
-
\ No newline at end of file
+})
+
+defineExpose({
+ editableDiv,
+ compVal,
+ mentionState,
+ openMentionDropdown,
+ onInput,
+})
+
+
+
diff --git a/client/components/forms/RichTextAreaInput.client.vue b/client/components/forms/RichTextAreaInput.client.vue
index 57aad7ae..be16f401 100644
--- a/client/components/forms/RichTextAreaInput.client.vue
+++ b/client/components/forms/RichTextAreaInput.client.vue
@@ -31,6 +31,7 @@
:options="quillOptions"
:disabled="disabled"
:style="inputStyle"
+ @ready="onEditorReady"
/>
@@ -59,7 +60,7 @@
@@ -103,12 +104,23 @@ watch(compVal, (val) => {
}
}, { immediate: true })
-// Move the mention extension registration to onMounted
-
-if (props.enableMentions && !Quill.imports['blots/mention']) {
+// Initialize mention extension
+if (props.enableMentions) {
+ // Register the mention extension with Quill
mentionState.value = registerMentionExtension(Quill)
}
+// Handle editor ready event
+const onEditorReady = (quillInstance) => {
+ // If we have a mention module, get its state
+ if (props.enableMentions && quillInstance) {
+ const mentionModule = quillInstance.getModule('mention')
+ if (mentionModule && mentionModule.state) {
+ mentionState.value = mentionModule.state
+ }
+ }
+}
+
const quillOptions = computed(() => {
const defaultOptions = {
placeholder: props.placeholder || '',
diff --git a/client/components/forms/components/MentionDropdown.vue b/client/components/forms/components/MentionDropdown.vue
index 7ce580b5..8909c4e6 100644
--- a/client/components/forms/components/MentionDropdown.vue
+++ b/client/components/forms/components/MentionDropdown.vue
@@ -1,7 +1,7 @@
@@ -43,7 +43,7 @@
-
+
-
-
\ No newline at end of file
+}
+
+watch(() => props.mentionState.open, (newValue) => {
+ if (newValue) {
+ selectedField.value = null
+ fallbackValue.value = ''
+ }
+})
+
+const insertMention = () => {
+ if (selectedField.value && props.mentionState.onInsert) {
+ props.mentionState.onInsert({
+ field: selectedField.value,
+ fallback: fallbackValue.value
+ })
+ }
+}
+
+const cancel = () => {
+ if (props.mentionState.onCancel) {
+ props.mentionState.onCancel()
+ }
+}
+
\ No newline at end of file
diff --git a/client/components/forms/components/QuillyEditor.vue b/client/components/forms/components/QuillyEditor.vue
index 3d0e0b40..1d0a43f7 100644
--- a/client/components/forms/components/QuillyEditor.vue
+++ b/client/components/forms/components/QuillyEditor.vue
@@ -8,6 +8,7 @@
-
diff --git a/client/components/global/Settings/SettingsSection.vue b/client/components/global/Settings/SettingsSection.vue
index 47eb4d65..990d44d3 100644
--- a/client/components/global/Settings/SettingsSection.vue
+++ b/client/components/global/Settings/SettingsSection.vue
@@ -1,6 +1,6 @@
diff --git a/client/components/open/components/EditSubmissionModal.vue b/client/components/open/components/EditSubmissionModal.vue
index 90b6308f..b339fdde 100644
--- a/client/components/open/components/EditSubmissionModal.vue
+++ b/client/components/open/components/EditSubmissionModal.vue
@@ -5,19 +5,16 @@
@close="emit('close')"
>
-
+
Update Submission
@@ -25,11 +22,13 @@
+
diff --git a/client/components/open/components/RecordOperations.vue b/client/components/open/components/RecordOperations.vue
index ab26ecd2..ac3637f4 100644
--- a/client/components/open/components/RecordOperations.vue
+++ b/client/components/open/components/RecordOperations.vue
@@ -4,7 +4,7 @@
orientation="horizontal"
>
-
+
+
-
+
+
{
submissionId: submissionId.value
}).then(result => {
if (result) {
- submittedData.value = result || {}
+ submittedData.value = formManager.form.data()
if (result?.submission_id) {
submissionId.value = result.submission_id
@@ -392,7 +398,9 @@ const addPasswordError = (msg) => {
}
defineExpose({
- addPasswordError
+ addPasswordError,
+ restart,
+ formManager
})
diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue
index 7b3df667..a56d8b7a 100644
--- a/client/components/open/forms/OpenForm.vue
+++ b/client/components/open/forms/OpenForm.vue
@@ -65,7 +65,7 @@
{{ currentFieldsPageBreak.next_btn_text }}
@@ -165,6 +165,8 @@ const handleDragDropped = (data) => {
workingFormStore.moveField(oldTargetIndex, newTargetIndex)
}
}
+
+const isProcessing = computed(() => props.formManager.state.isProcessing)
diff --git a/client/pages/privacy-policy.vue b/client/pages/privacy-policy.vue
index 6a2e54d6..26d90046 100644
--- a/client/pages/privacy-policy.vue
+++ b/client/pages/privacy-policy.vue
@@ -6,7 +6,7 @@
Privacy Policy
@@ -16,26 +16,19 @@
diff --git a/client/pages/terms-conditions.vue b/client/pages/terms-conditions.vue
index acdbe201..e76a4683 100644
--- a/client/pages/terms-conditions.vue
+++ b/client/pages/terms-conditions.vue
@@ -6,7 +6,7 @@
Terms & Conditions
@@ -16,26 +16,19 @@
diff --git a/client/plugins/0.error-handler.client.js b/client/plugins/0.error-handler.client.js
new file mode 100644
index 00000000..29025e51
--- /dev/null
+++ b/client/plugins/0.error-handler.client.js
@@ -0,0 +1,21 @@
+export default defineNuxtPlugin((nuxtApp) => {
+const router = useRouter()
+
+ router.onError((error) => {
+ if (
+ error.message.includes('Failed to fetch dynamically imported module') ||
+ error.message.includes('Failed to load resource')
+ ) {
+ window.location.reload()
+ }
+ })
+
+ nuxtApp.hook('app:error', (error) => {
+ if (
+ error.message.includes('Loading chunk') ||
+ error.message.includes('Failed to load resource')
+ ) {
+ window.location.reload()
+ }
+ })
+})
\ No newline at end of file
diff --git a/client/plugins/crisp.client.js b/client/plugins/crisp.client.js
index a7d8cbf3..327f9e4d 100644
--- a/client/plugins/crisp.client.js
+++ b/client/plugins/crisp.client.js
@@ -3,7 +3,9 @@ import { Crisp } from "crisp-sdk-web"
export default defineNuxtPlugin(() => {
const isIframe = useIsIframe()
const crispWebsiteId = useRuntimeConfig().public.crispWebsiteId
- if (crispWebsiteId && !isIframe) {
+ const isPublicFormPage = useRoute().name === 'forms-slug'
+
+ if (crispWebsiteId && !isIframe && !isPublicFormPage) {
Crisp.configure(crispWebsiteId)
window.Crisp = Crisp
}
diff --git a/client/plugins/gtm.client.js b/client/plugins/gtm.client.js
new file mode 100644
index 00000000..d2c8c338
--- /dev/null
+++ b/client/plugins/gtm.client.js
@@ -0,0 +1,31 @@
+import gtmConfig from '../gtm'
+
+export default defineNuxtPlugin(() => {
+ const route = useRoute()
+ const isIframe = useIsIframe()
+ const isPublicFormPage = route.name === 'forms-slug'
+
+ // Only enable GTM if not in a form page (for respondents) and not in an iframe
+ if (!isPublicFormPage && !isIframe && process.env.NUXT_PUBLIC_GTM_CODE) {
+ // Initialize GTM manually only when needed
+ const gtm = useGtm()
+
+ // Override the enabled setting to true for this session
+ gtmConfig.enabled = true
+
+ // Watch for route changes to track page views
+ watch(() => route.fullPath, () => {
+ if (!route.name || route.name !== 'forms-slug') {
+ gtm.trackView(route.name, route.fullPath)
+ }
+ }, { immediate: true })
+
+ return {
+ provide: {
+ gtm
+ }
+ }
+ }
+
+ return {}
+})
\ No newline at end of file
diff --git a/client/sentry.client.config.ts b/client/sentry.client.config.ts
index 150cce0b..4ace9217 100644
--- a/client/sentry.client.config.ts
+++ b/client/sentry.client.config.ts
@@ -24,23 +24,37 @@ Sentry.init({
beforeSend (event) {
if (event.exception?.values?.length) {
+ const errorType = event.exception.values[0]?.type || '';
+ const errorValue = event.exception.values[0]?.value || '';
+
// Don't send validation exceptions to Sentry
if (
- event.exception.values[0]?.type === 'FetchError'
- && (event.exception.values[0]?.value?.includes('422')
- || event.exception.values[0]?.value?.includes('401'))
- )
+ errorType === 'FetchError' &&
+ (errorValue.includes('422') || errorValue.includes('401'))
+ ) {
return null
+ }
+
+ // Filter out chunk loading errors
+ if (
+ errorValue.includes('Failed to fetch dynamically imported module') ||
+ errorValue.includes('Loading chunk') ||
+ errorValue.includes('Failed to load resource')
+ ) {
+ return null
+ }
}
+
const authStore = useAuthStore()
if (authStore.check) {
+ const user = authStore.user as { id?: string; email?: string } | null
Sentry.setUser({
- id: authStore.user?.id,
- email: authStore.user?.email
+ id: user?.id,
+ email: user?.email
})
event.user = {
- id: authStore.user?.id,
- email: authStore.user?.email
+ id: user?.id,
+ email: user?.email
}
}
return event
diff --git a/client/stores/notion_cms.js b/client/stores/notion_cms.js
new file mode 100644
index 00000000..6d0b9538
--- /dev/null
+++ b/client/stores/notion_cms.js
@@ -0,0 +1,127 @@
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import opnformConfig from "~/opnform.config.js"
+
+function notionApiFetch (entityId, type = 'table') {
+ const apiUrl = opnformConfig.notion.worker
+ return useFetch(`${apiUrl}/${type}/${entityId}`)
+}
+
+function fetchNotionDatabasePages (databaseId) {
+ return notionApiFetch(databaseId)
+}
+
+function fetchNotionPageContent (pageId) {
+ return notionApiFetch(pageId, 'page')
+}
+
+export const useNotionCmsStore = defineStore('notion_cms', () => {
+
+ // State
+ const databases = ref({})
+ const pages = ref({})
+ const pageContents = ref({})
+ const slugToIdMap = ref({})
+
+ const loading = ref(false)
+
+ // Actions
+ const loadDatabase = (databaseId) => {
+ return new Promise((resolve, reject) => {
+ if (databases.value[databaseId]) return resolve()
+
+ loading.value = true
+ return fetchNotionDatabasePages(databaseId).then((response) => {
+ databases.value[databaseId] = response.data.value.map(page => formatId(page.id))
+ response.data.value.forEach(page => {
+ pages.value[formatId(page.id)] = {
+ ...page,
+ id: formatId(page.id)
+ }
+ const slug = page.Slug ?? page.slug ?? null
+ if (slug) {
+ setSlugToIdMap(slug, page.id)
+ }
+ })
+ loading.value = false
+ resolve()
+ }).catch((error) => {
+ loading.value = false
+ console.error(error)
+ reject(error)
+ })
+ })
+ }
+ const loadPage = async (pageId) => {
+ return new Promise((resolve, reject) => {
+ if (pageContents.value[pageId]) return resolve('in already here')
+ loading.value = true
+ return fetchNotionPageContent(pageId).then((response) => {
+ pageContents.value[formatId(pageId)] = response.data.value
+ loading.value = false
+ return resolve('in finishg')
+ }).catch((error) => {
+ console.error(error)
+ loading.value = false
+ return reject(error)
+ })
+ })
+ }
+
+ const loadPageBySlug = (slug) => {
+ if (!slugToIdMap.value[slug.toLowerCase()]) return
+ loadPage(slugToIdMap.value[slug.toLowerCase()])
+ }
+
+ const formatId = (id) => id.replaceAll('-', '')
+
+ const getPage = (pageId) => {
+ return {
+ ...pages.value[pageId],
+ blocks: getPageBody(pageId)
+ }
+ }
+
+ const getPageBody = (pageId) => {
+ return pageContents.value[pageId]
+ }
+
+ const setSlugToIdMap = (slug, pageId) => {
+ if (!slug) return
+ slugToIdMap.value[slug.toLowerCase()] = formatId(pageId)
+ }
+
+ const getPageBySlug = (slug) => {
+ if (!slug) return
+ const pageId = slugToIdMap.value[slug.toLowerCase()]
+ return getPage(pageId)
+ }
+
+// Getters
+ const databasePages = (databaseId) => computed(() => databases.value[databaseId]?.map(id => pages.value[id]) || [])
+ const pageContent = (pageId) => computed(() => pageContents.value[pageId])
+ const pageBySlug = (slug) => computed(() => getPageBySlug(slug))
+
+ return {
+ // state
+ loading,
+ databases,
+ pages,
+ pageContents,
+ slugToIdMap,
+
+ // actions
+ loadDatabase,
+ loadPage,
+ loadPageBySlug,
+ getPage,
+ getPageBody,
+ setSlugToIdMap,
+ getPageBySlug,
+
+ // getters
+ databasePages,
+ pageContent,
+ pageBySlug
+ }
+})
diff --git a/client/stores/notion_pages.js b/client/stores/notion_pages.js
deleted file mode 100644
index 608f78c9..00000000
--- a/client/stores/notion_pages.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { defineStore } from "pinia"
-import { useContentStore } from "~/composables/stores/useContentStore.js"
-import opnformConfig from "~/opnform.config.js"
-export const useNotionPagesStore = defineStore("notion_pages", () => {
- const contentStore = useContentStore()
-
- const load = (pageId) => {
- contentStore.startLoading()
-
- const apiUrl = opnformConfig.notion.worker
- return useFetch(`${apiUrl}/page/${pageId}`)
- .then(({ data }) => {
- const val = data.value
- val["id"] = pageId
- contentStore.save(val)
- })
- .finally(() => {
- contentStore.stopLoading()
- })
- }
-
- return {
- ...contentStore,
- load,
- }
-})
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 36084769..7b85cbef 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -4,12 +4,6 @@ services:
api:
image: jhumanj/opnform-api:dev
container_name: opnform-api
- build:
- context: .
- dockerfile: docker/Dockerfile.api
- args:
- APP_ENV: local
- COMPOSER_FLAGS: ""
volumes:
- ./api:/usr/share/nginx/html:delegated
- /usr/share/nginx/html/vendor # Exclude vendor directory from the mount
@@ -44,9 +38,6 @@ services:
ui:
image: jhumanj/opnform-client:dev
container_name: opnform-client
- build:
- context: .
- dockerfile: docker/Dockerfile.client
command: sh -c "npm install && NITRO_HOST=0.0.0.0 NITRO_PORT=3000 npm run dev"
volumes:
- ./client:/app:delegated
diff --git a/docker-compose.yml b/docker-compose.yml
index 78cc3d33..22402e96 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,12 +3,6 @@ services:
api: &api-environment
image: jhumanj/opnform-api:latest
container_name: opnform-api
- build: &api-build
- context: .
- dockerfile: docker/Dockerfile.api
- args:
- APP_ENV: production
- COMPOSER_FLAGS: --no-dev
volumes: &api-environment-volumes
- ./api/storage:/usr/share/nginx/html/storage:rw
environment: &api-env
@@ -35,7 +29,6 @@ services:
api-worker:
<<: *api-environment
container_name: opnform-api-worker
- build: *api-build
command: ["php", "artisan", "queue:work"]
volumes: *api-environment-volumes
environment:
@@ -48,7 +41,6 @@ services:
api-scheduler:
<<: *api-environment
container_name: opnform-api-scheduler
- build: *api-build
command: ["php", "artisan", "schedule:work"]
volumes: *api-environment-volumes
environment:
@@ -65,9 +57,6 @@ services:
ui:
image: jhumanj/opnform-client:latest
container_name: opnform-client
- build:
- context: .
- dockerfile: docker/Dockerfile.client
env_file:
- ./client/.env
diff --git a/docker/Dockerfile.client b/docker/Dockerfile.client
index 846ee368..eac0d785 100644
--- a/docker/Dockerfile.client
+++ b/docker/Dockerfile.client
@@ -20,7 +20,8 @@ RUN npm cache clean --force && \
# RUN npm install esbuild@0.21.5
ADD ./client/ /app/
-RUN npm run build
+# Increase Node memory limit to prevent out of memory error during build
+RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
FROM node:20-alpine
WORKDIR /app