diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77bcc3b..0e19725 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,9 @@ jobs: - name: Run tests with phpunit run: vendor/bin/phpunit tests + - name: Run tests with vitest + run: node node_modules/vitest/vitest.mjs --run + - name: Upload Panther screenshots if: failure() uses: actions/upload-artifact@v4 diff --git a/assets/router/index.js b/assets/router/index.js index f1d66d3..a9cbdf0 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -8,6 +8,8 @@ import CampaignEditView from '../vue/views/CampaignEditView.vue' import TemplatesView from '../vue/views/TemplatesView.vue' import TemplateEditView from '../vue/views/TemplateEditView.vue' import BouncesView from '../vue/views/BouncesView.vue' +import PublicPagesView from '../vue/views/PublicPagesView.vue' +import PublicPageEditView from '../vue/views/PublicPageEditView.vue' export const router = createRouter({ history: createWebHistory(), @@ -23,6 +25,9 @@ export const router = createRouter({ { path: '/campaigns/:campaignId/edit', name: 'campaign-edit', component: CampaignEditView, meta: { title: 'Edit Campaign' } }, { path: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } }, { path: '/bounces', name: 'bounces', component: BouncesView, meta: { title: 'Bounces' } }, + { path: '/public', name: 'public-pages', component: PublicPagesView, meta: { title: 'Public Pages' } }, + { path: '/public/create', name: 'public-page-create', component: PublicPageEditView, meta: { title: 'Create Public Page' } }, + { path: '/public/:pageId/edit', name: 'public-page-edit', component: PublicPageEditView, meta: { title: 'Edit Public Page' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/assets/styles/color.css b/assets/styles/color.css new file mode 100644 index 0000000..accbd53 --- /dev/null +++ b/assets/styles/color.css @@ -0,0 +1,33 @@ +:root { + --page-bg: #e5e7eb; + --primary-text: #374151; + + --header-bg: #1e1b4b; + --logo-color: #c7d2fe; + + --topbar-gradient-start: #312e81; + --topbar-gradient-middle: #3730a3; + --topbar-gradient-end: #4338ca; + --topbar-border: #4f46e5; + + --card-bg: #ffffff; + --card-border: #e5e7eb; + + --input-bg: #ffffff; + --input-border: #d1d5db; + + --field-frame-bg: #f9fafb; + --field-frame-border: #e5e7eb; + + --required-color: #dc2626; + + --error-bg: #fef2f2; + --error-border: #fca5a5; + --error-text: #dc2626; + + --success-bg: #f0fdf4; + --success-border: #86efac; + --success-text: #16a34a; + + --footer-border: #e5e7eb; +} diff --git a/assets/styles/subscribe.css b/assets/styles/subscribe.css new file mode 100644 index 0000000..2ca756f --- /dev/null +++ b/assets/styles/subscribe.css @@ -0,0 +1,354 @@ +body { + margin: 0; + background: var(--page-bg); + color: var(--primary-text); + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; +} + +#container { + min-width: 300px; + margin: 0 auto; +} + +#header { + background: var(--primary-text); + box-sizing: border-box; + position: relative; + left: 50%; + transform: translateX(-50%); + width: 100vw; +} + +#logo { + color: var(--logo-color); + margin-top: 0px; + font-size: 48px; + text-align: center; + padding-bottom: 20px; +} + +#logo a { + color: var(--logo-color); + text-decoration: none; + font-size: 2rem; +} + +#wrapper { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +#mainContent { + min-height: 500px; +} + +#footer { + padding: 20px; + border-top: 1px solid var(--footer-border); +} + +.inline-field { + display: flex; + align-items: center; + gap: 12px; +} + +.inline-field .legacy-label { + margin: 0; + white-space: nowrap; + min-width: 80px; /* adjust as needed */ +} + +.inline-field .legacy-input { + flex: 1; +} + +.checkbox-inline { + display: flex; + align-items: center; + gap: 8px; +} + +.checkbox-inline .legacy-label { + margin: 0; +} + +.checkbox-inline input[type="checkbox"] { + margin: 0; + flex-shrink: 0; +} + +.checkbox-field label { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 15px; + margin-bottom: 0; +} + +.checkbox-field input[type="checkbox"] { + margin: 0; +} + +.legacy-topbar { + background: linear-gradient(90deg, var(--topbar-gradient-start) 0%, var(--topbar-gradient-middle) 55%, var(--topbar-gradient-end) 100%); + border-bottom: 1px solid var(--topbar-border); + box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.1); + margin: 10px 0 0; + min-height: 68px; +} + +.legacy-topbar-inner { + box-sizing: border-box; + color: #e0e7ff; + font-size: 33px; + font-weight: 700; + line-height: 1; + margin: 0 auto; + max-width: 930px; + padding: 6px 18px; +} + +.legacy-page-shell { + padding: 40px 16px 60px; +} + +.legacy-card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 16px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.06); + margin: 0 auto; + max-width: 900px; + padding: 52px 30px 36px; +} + +.legacy-form { + margin: 0; +} + +.legacy-html-text { + color: var(--primary-text); + font-size: 13px; + line-height: 1.5; + margin-bottom: 18px; +} + +.legacy-required-note { + color: var(--required-color); + font-size: 12px; + font-weight: 400; + margin: 10px 0 14px; + text-transform: uppercase; +} + +.legacy-field-group { + margin-bottom: 18px; +} + +.legacy-label { + color: var(--primary-text); + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.legacy-input { + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: 6px; + box-sizing: border-box; + color: var(--primary-text); + font-size: 13px; + max-width: none; + padding: 7px 10px; + transition: border-color 0.15s, box-shadow 0.15s; + width: 100%; +} + +.legacy-input:focus { + outline: none; + border-color: #543ff6; + box-shadow: 0 0 0 3px rgba(84, 63, 246, 0.12); +} + +.legacy-field-frame { + background: var(--field-frame-bg); + border: 1px solid var(--field-frame-border); + border-radius: 8px; + margin: 14px 0 12px; + padding: 10px; +} + +.legacy-field-row { + align-items: center; + display: grid; + gap: 20px; + grid-template-columns: 220px minmax(0, 1fr); + margin-bottom: 12px; +} + +.legacy-field-row .legacy-label { + margin: 0; +} + +.legacy-option-label, +.legacy-list-option, +.legacy-check-label { + color: var(--primary-text); + font-size: 13px; +} + +.legacy-lists { + border: 0; + margin: 16px 0; + padding: 0; +} + +.legacy-lists legend { + color: var(--primary-text); + font-size: 14px; + font-weight: 700; + margin-bottom: 8px; + padding: 0; +} + +.legacy-list-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.legacy-list-option { + align-items: flex-start; + display: flex; + gap: 8px; +} + +.legacy-actions { + align-items: center; + display: flex; + gap: 14px; + margin-top: 10px; +} + +.legacy-button { + background: #543ff6; + border: 1px solid transparent; + border-radius: 6px; + color: #ffffff; + cursor: pointer; + font-size: 14px; + font-weight: 600; + line-height: 1; + padding: 8px 16px; + transition: background-color 0.15s; +} + +.legacy-button:hover { + background: #303F9F; +} + +.adminmessage { + background: var(--page-bg); + border: 1px solid var(--card-border); + border-radius: 8px; + color: var(--primary-text); + font-size: 12px; + margin-bottom: 14px; + padding: 14px 14px 12px; +} + +.adminmessage p { + margin: 0 0 8px; +} + +.adminmessage p:last-child { + margin-bottom: 0; +} + +.adminmessage .button { + background: #543ff6; + border: 1px solid #fff; + border-radius: 6px; + color: #fff; + display: inline-block; + padding: 4px 8px; + text-decoration: none; +} + +.legacy-confirmation { + color: var(--primary-text); + font-size: 13px; + margin: 10px 0 16px; +} + +.legacy-powered-by { + margin-top: 26px; + text-align: center; +} + +.legacy-powered-by img { + display: inline-block; +} + +.legacy-error-box { + background: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: 6px; + color: var(--error-text); + font-size: 13px; + margin-bottom: 12px; + padding: 10px; +} + +.legacy-error-box p { + margin: 0 0 6px; +} + +.legacy-error-box p:last-child { + margin-bottom: 0; +} + +.legacy-success { + background: var(--success-bg); + border: 1px solid var(--success-border); + border-radius: 6px; + color: var(--success-text); + font-size: 13px; + margin-bottom: 12px; + padding: 10px; +} + +.legacy-hidden { + display: none; +} + +input[type="checkbox"] { + accent-color: var(--topbar-gradient-end); +} + +.poweredby { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 9999; +} + +@media (max-width: 720px) { + .legacy-topbar-inner { + font-size: 34px; + } + + .legacy-card { + padding: 28px 16px; + } + + .legacy-field-row { + gap: 6px; + grid-template-columns: 1fr; + } +} diff --git a/assets/vue/api.js b/assets/vue/api.js index 43e2e12..d2de813 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -1,9 +1,11 @@ import { + AdminClient, CampaignClient, Client, ListMessagesClient, ListClient, StatisticsClient, + SubscribePagesClient, SubscribersClient, SubscriptionClient, SubscriberAttributesClient, @@ -27,7 +29,9 @@ const redirectToLogin = () => { return; } isAuthenticationRedirectInProgress = true; - window.location.href = AUTHENTICATION_REDIRECT_PATH; + const redirectTarget = `${window.location.pathname}${window.location.search}`; + const search = new URLSearchParams({ redirect: redirectTarget }).toString(); + window.location.href = `${AUTHENTICATION_REDIRECT_PATH}?${search}`; }; const appElement = document.getElementById('vue-app'); @@ -59,11 +63,13 @@ client.axiosInstance?.interceptors?.response?.use( ); export const subscribersClient = new SubscribersClient(client); +export const adminClient = new AdminClient(client); export const listClient = new ListClient(client); export const campaignClient = new CampaignClient(client); export const listMessagesClient = new ListMessagesClient(client); export const statisticsClient = new StatisticsClient(client); export const subscriptionClient = new SubscriptionClient(client); +export const subscribePagesClient = new SubscribePagesClient(client); export const subscriberAttributesClient = new SubscriberAttributesClient(client); export const templateClient = new TemplatesClient(client); export const bouncesClient = new BouncesClient(client); @@ -100,4 +106,48 @@ export const fetchAllLists = async ({ limit = 100, maxPages = 100 } = {}) => { return lists; }; +export const fetchAllAdmins = async ({ limit = 100, maxPages = 100 } = {}) => { + const admins = []; + let afterId = null; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const response = await adminClient.getAdministrators(afterId, limit); + const items = Array.isArray(response?.items) ? response.items : []; + admins.push(...items); + + const hasMore = response?.pagination?.hasMore === true; + const nextCursor = response?.pagination?.nextCursor; + + if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { + break; + } + + afterId = nextCursor; + } + + return admins; +}; + +export const fetchAllAttributeDefinitions = async ({ limit = 100, maxPages = 100 } = {}) => { + const attributes = []; + let afterId = null; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const response = await subscriberAttributesClient.getAttributeDefinitions(afterId, limit); + const items = Array.isArray(response?.items) ? response.items : []; + attributes.push(...items); + + const hasMore = response?.pagination?.hasMore === true; + const nextCursor = response?.pagination?.nextCursor; + + if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { + break; + } + + afterId = nextCursor; + } + + return attributes; +}; + export default client; diff --git a/assets/vue/components/base/BaseBadge.spec.js b/assets/vue/components/base/BaseBadge.spec.js index df0507a..3728e8a 100644 --- a/assets/vue/components/base/BaseBadge.spec.js +++ b/assets/vue/components/base/BaseBadge.spec.js @@ -2,6 +2,19 @@ import { mount } from '@vue/test-utils' import BaseBadge from './BaseBadge.vue' describe('BaseBadge', () => { + it('applies shared base badge classes', () => { + const wrapper = mount(BaseBadge) + const classes = wrapper.get('span').classes() + + expect(classes).toContain('inline-flex') + expect(classes).toContain('items-center') + expect(classes).toContain('px-2') + expect(classes).toContain('py-0.5') + expect(classes).toContain('rounded-full') + expect(classes).toContain('text-xs') + expect(classes).toContain('font-medium') + }) + it('renders neutral variant by default', () => { const wrapper = mount(BaseBadge, { slots: { @@ -28,6 +41,30 @@ describe('BaseBadge', () => { const classes = wrapper.get('span').classes() expect(classes).toContain('bg-indigo-50') expect(classes).toContain('text-ext-wf3') + expect(classes).toContain('border') + expect(classes).toContain('border-indigo-100') expect(wrapper.text()).toContain('10') }) + + it('falls back to neutral styles for unknown variant', () => { + const wrapper = mount(BaseBadge, { + props: { + variant: 'unexpected', + }, + }) + + const classes = wrapper.get('span').classes() + expect(classes).toContain('bg-gray-100') + expect(classes).toContain('text-gray-800') + }) + + it('forwards attributes to the root span', () => { + const wrapper = mount(BaseBadge, { + attrs: { + 'data-testid': 'badge', + }, + }) + + expect(wrapper.get('span').attributes('data-testid')).toBe('badge') + }) }) diff --git a/assets/vue/components/base/BaseCard.spec.js b/assets/vue/components/base/BaseCard.spec.js new file mode 100644 index 0000000..eaaad91 --- /dev/null +++ b/assets/vue/components/base/BaseCard.spec.js @@ -0,0 +1,75 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' +import BaseCard from './BaseCard.vue' + +describe('BaseCard.vue', () => { + describe('default variant', () => { + it('renders with default variant when no prop is passed', () => { + const wrapper = mount(BaseCard) + expect(wrapper.classes()).toContain('rounded-lg') + expect(wrapper.classes()).toContain('shadow-sm') + expect(wrapper.classes()).toContain('border') + expect(wrapper.classes()).toContain('border-gray-100') + expect(wrapper.classes()).toContain('bg-white') + }) + + it('renders body with default padding', () => { + const wrapper = mount(BaseCard) + const body = wrapper.find('[data-testid="card-body"]') + expect(body.classes()).toContain('p-4') + }) + }) + + describe('subtle variant', () => { + it('applies subtle card classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'subtle' } }) + expect(wrapper.classes()).toContain('bg-gray-50') + expect(wrapper.classes()).toContain('border-0') + expect(wrapper.classes()).not.toContain('bg-white') + }) + + it('applies subtle body classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'subtle' } }) + const body = wrapper.find('[data-testid="card-body"]') + expect(body.classes()).toContain('p-4') + }) + }) + + describe('danger variant', () => { + it('applies danger card classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'danger' } }) + expect(wrapper.classes()).toContain('bg-red-600') + expect(wrapper.classes()).toContain('text-white') + expect(wrapper.classes()).toContain('border-0') + }) + + it('applies danger body classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'danger' } }) + const body = wrapper.find('[data-testid="card-body"]') + expect(body.classes()).toContain('p-4') + }) + }) + + describe('success variant', () => { + it('applies success card classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'success' } }) + expect(wrapper.classes()).toContain('bg-green-600') + expect(wrapper.classes()).toContain('text-white') + expect(wrapper.classes()).toContain('border-0') + }) + + it('applies success body classes', () => { + const wrapper = mount(BaseCard, { props: { variant: 'success' } }) + const body = wrapper.find('[data-testid="card-body"]') + expect(body.classes()).toContain('p-4') + }) + }) + + describe('unknown variant fallback', () => { + it('falls back to default classes for an unrecognised variant', () => { + const wrapper = mount(BaseCard, { props: { variant: 'ghost' } }) + expect(wrapper.classes()).toContain('bg-white') + expect(wrapper.classes()).toContain('border-gray-100') + }) + }) +}) diff --git a/assets/vue/components/base/BaseCard.vue b/assets/vue/components/base/BaseCard.vue index 2278c74..5a15a04 100644 --- a/assets/vue/components/base/BaseCard.vue +++ b/assets/vue/components/base/BaseCard.vue @@ -1,6 +1,6 @@