+
+
+ { sprintf(
+ /* translators: 1: imported sponsor count, 2: skipped row count. */
+ __(
+ '%1$d imported; %2$d skipped',
+ 'sponsor-directory-block'
+ ),
+ report.matchedCount || 0,
+ report.skippedCount || 0
+ ) }
+
+
+ { skipped.length > 0 && (
+
+
+ { __( 'Skipped rows', 'sponsor-directory-block' ) }
+
+
+ { skipped.map( ( item, index ) => (
+ -
+ { sprintf(
+ /* translators: 1: CSV row number, 2: reason. */
+ __(
+ 'Row %1$d: %2$s',
+ 'sponsor-directory-block'
+ ),
+ item.row,
+ item.reason
+ ) }
+
+ ) ) }
+
+
+ ) }
+ { warnings.length > 0 && (
+
+
+ { __( 'Warnings', 'sponsor-directory-block' ) }
+
+
+ { warnings.map( ( item, index ) => (
+ -
+ { sprintf(
+ /* translators: 1: CSV row number, 2: reason. */
+ __(
+ 'Row %1$d: %2$s',
+ 'sponsor-directory-block'
+ ),
+ item.row,
+ item.reason
+ ) }
+
+ ) ) }
+
+
+ ) }
+
+ );
+};
+
+export default function Edit( { attributes, setAttributes } ) {
+ const { csvUrl, sponsors, importedAt, importReport } = attributes;
+ const [ url, setUrl ] = useState( csvUrl );
+ const [ isImporting, setIsImporting ] = useState( false );
+ const [ error, setError ] = useState( '' );
+ const [ success, setSuccess ] = useState( '' );
+
+ const importSponsors = async () => {
+ setError( '' );
+ setSuccess( '' );
+ setIsImporting( true );
+
+ try {
+ const response = await apiFetch( {
+ path: '/sponsor-directory/v1/import',
+ method: 'POST',
+ data: { url: url.trim() },
+ } );
+
+ setAttributes( {
+ csvUrl: url.trim(),
+ sponsors: response.sponsors,
+ importedAt: response.importedAt,
+ importReport: response.report,
+ } );
+ setSuccess(
+ sprintf(
+ /* translators: %d: imported sponsor count. */
+ __( 'Imported %d sponsors.', 'sponsor-directory-block' ),
+ response.report.matchedCount
+ )
+ );
+ } catch ( importError ) {
+ setError(
+ importError.message ||
+ __(
+ 'The sponsor import failed.',
+ 'sponsor-directory-block'
+ )
+ );
+ } finally {
+ setIsImporting( false );
+ }
+ };
+
+ return (
+
+
{ __( 'Sponsor Directory', 'sponsor-directory-block' ) }
+
+ { __(
+ 'Publish the Google Sheet as CSV, enter its URL, and import a read-only snapshot.',
+ 'sponsor-directory-block'
+ ) }
+
+
+
+
+ { isImporting && }
+
+ { error && (
+
setError( '' ) }
+ >
+ { error }
+
+ ) }
+ { success && (
+
setSuccess( '' ) }
+ >
+ { success }
+
+ ) }
+ { sponsors.length === 0 ? (
+
+ { __(
+ 'No sponsors have been imported.',
+ 'sponsor-directory-block'
+ ) }
+
+ ) : (
+ <>
+
+ { sprintf(
+ /* translators: %s: import date and time. */
+ __(
+ 'Last imported: %s',
+ 'sponsor-directory-block'
+ ),
+ importedAt
+ ? new Date( importedAt ).toLocaleString()
+ : ''
+ ) }
+
+
+
+
+
+
+ |
+ { __(
+ 'Sponsor',
+ 'sponsor-directory-block'
+ ) }
+ |
+
+ { __(
+ 'Post ID',
+ 'sponsor-directory-block'
+ ) }
+ |
+
+ { __(
+ 'Level',
+ 'sponsor-directory-block'
+ ) }
+ |
+
+
+
+ { sponsors.map( ( sponsor ) => (
+
+ | { sponsor.name } |
+ { sponsor.postId } |
+ { sponsor.level || '—' } |
+
+ ) ) }
+
+
+
+ >
+ ) }
+
+ );
+}
diff --git a/web/wp-content/plugins/sponsor-directory-block/src/editor.scss b/web/wp-content/plugins/sponsor-directory-block/src/editor.scss
new file mode 100644
index 00000000..9c448ced
--- /dev/null
+++ b/web/wp-content/plugins/sponsor-directory-block/src/editor.scss
@@ -0,0 +1,51 @@
+.sponsor-directory-editor {
+ padding: 24px;
+ border: 1px solid #dcdcde;
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ &__empty,
+ &__source,
+ &__report {
+ margin-top: 16px;
+ }
+
+ &__report {
+ padding: 12px 16px;
+ background: #f6f7f7;
+
+ ul {
+ margin: 4px 0 12px 20px;
+ }
+ }
+
+ &__preview {
+ max-height: 360px;
+ overflow: auto;
+ margin-top: 16px;
+ border: 1px solid #dcdcde;
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ th,
+ td {
+ padding: 8px 10px;
+ border-bottom: 1px solid #dcdcde;
+ text-align: left;
+ }
+
+ th {
+ position: sticky;
+ top: 0;
+ background: #f6f7f7;
+ }
+ }
+}
diff --git a/web/wp-content/plugins/sponsor-directory-block/src/filter.js b/web/wp-content/plugins/sponsor-directory-block/src/filter.js
new file mode 100644
index 00000000..5229ced8
--- /dev/null
+++ b/web/wp-content/plugins/sponsor-directory-block/src/filter.js
@@ -0,0 +1,20 @@
+export const normalizeFilterValue = ( value = '' ) =>
+ String( value ).trim().toLocaleLowerCase();
+
+export const sponsorMatchesFilters = (
+ sponsor,
+ selectedCategory = '',
+ selectedLevel = ''
+) => {
+ const category = normalizeFilterValue( selectedCategory );
+ const level = normalizeFilterValue( selectedLevel );
+ const sponsorLevel = normalizeFilterValue( sponsor.level );
+ const sponsorCategories = Array.isArray( sponsor.categories )
+ ? sponsor.categories.map( normalizeFilterValue )
+ : [];
+
+ return (
+ ( ! category || sponsorCategories.includes( category ) ) &&
+ ( ! level || sponsorLevel === level )
+ );
+};
diff --git a/web/wp-content/plugins/sponsor-directory-block/src/filter.test.js b/web/wp-content/plugins/sponsor-directory-block/src/filter.test.js
new file mode 100644
index 00000000..4a34be5f
--- /dev/null
+++ b/web/wp-content/plugins/sponsor-directory-block/src/filter.test.js
@@ -0,0 +1,44 @@
+import { normalizeFilterValue, sponsorMatchesFilters } from './filter';
+
+const sponsor = {
+ level: ' Gold ',
+ categories: [ 'Cloud Native', 'AI' ],
+};
+
+describe( 'sponsor directory filtering', () => {
+ test( 'normalizes whitespace and case', () => {
+ expect( normalizeFilterValue( ' Cloud Native ' ) ).toBe(
+ 'cloud native'
+ );
+ } );
+
+ test( 'matches all sponsors when both filters are empty', () => {
+ expect( sponsorMatchesFilters( sponsor ) ).toBe( true );
+ } );
+
+ test( 'matches category and level independently', () => {
+ expect( sponsorMatchesFilters( sponsor, 'ai', '' ) ).toBe( true );
+ expect( sponsorMatchesFilters( sponsor, '', 'gold' ) ).toBe( true );
+ } );
+
+ test( 'uses AND semantics when both filters are selected', () => {
+ expect( sponsorMatchesFilters( sponsor, 'cloud native', 'gold' ) ).toBe(
+ true
+ );
+ expect(
+ sponsorMatchesFilters( sponsor, 'cloud native', 'silver' )
+ ).toBe( false );
+ expect( sponsorMatchesFilters( sponsor, 'security', 'gold' ) ).toBe(
+ false
+ );
+ } );
+
+ test( 'does not match specific filters when metadata is empty', () => {
+ expect(
+ sponsorMatchesFilters( { level: '', categories: [] }, 'ai', '' )
+ ).toBe( false );
+ expect(
+ sponsorMatchesFilters( { level: '', categories: [] }, '', 'gold' )
+ ).toBe( false );
+ } );
+} );
diff --git a/web/wp-content/plugins/sponsor-directory-block/src/index.js b/web/wp-content/plugins/sponsor-directory-block/src/index.js
new file mode 100644
index 00000000..9ec3095c
--- /dev/null
+++ b/web/wp-content/plugins/sponsor-directory-block/src/index.js
@@ -0,0 +1,10 @@
+import { registerBlockType } from '@wordpress/blocks';
+
+import metadata from './block.json';
+import Edit from './edit';
+import './style.scss';
+
+registerBlockType( metadata.name, {
+ edit: Edit,
+ save: () => null,
+} );
diff --git a/web/wp-content/plugins/sponsor-directory-block/src/style.scss b/web/wp-content/plugins/sponsor-directory-block/src/style.scss
new file mode 100644
index 00000000..55cb7612
--- /dev/null
+++ b/web/wp-content/plugins/sponsor-directory-block/src/style.scss
@@ -0,0 +1,310 @@
+.wp-block-lf-sponsor-directory.sponsor-directory {
+
+ .sponsor-directory__filters {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 280px)) auto;
+ align-items: end;
+ gap: 16px;
+ margin-bottom: 18px;
+ }
+
+ .sponsor-directory__filter {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+
+ label {
+ font-weight: 700;
+ }
+
+ select {
+ width: 100%;
+ min-height: 44px;
+ margin-bottom: 0;
+ padding: 8px 36px 8px 10px;
+ }
+ }
+
+ .sponsor-directory__reset {
+ min-height: 44px;
+ padding: 8px 18px;
+ border: 2px solid currentcolor;
+ background: transparent;
+ color: inherit;
+ font-weight: 700;
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: 3px solid currentcolor;
+ outline-offset: 3px;
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+ }
+
+ &:hover:not(:disabled) {
+ background: #222;
+ color: #fff;
+ }
+ }
+
+ .sponsor-directory__result-count {
+ margin: 0 0 18px;
+ font-weight: 700;
+ }
+
+ .sponsor-directory__no-results {
+ padding: 24px;
+ background: #f4f4f4;
+ text-align: center;
+ }
+
+ .sponsor-directory__grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
+ justify-items: start;
+ gap: 24px;
+ }
+
+ .sponsor-directory__card {
+ width: 100%;
+ max-width: 350px;
+ min-width: 0;
+ }
+
+ .sponsor-directory__card-button {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ width: 100%;
+ height: 100%;
+ padding: 24px;
+ border: 1px solid #d5d5d5;
+ border-radius: 2px;
+ background: #fff;
+ color: #222;
+ cursor: pointer;
+ text-align: left;
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease,
+ transform 0.2s ease;
+
+ &:hover {
+ border-color: #777;
+ box-shadow: 0 6px 18px rgb(0 0 0 / 12%);
+ transform: translateY(-2px);
+ }
+
+ &:focus-visible {
+ outline: 3px solid currentcolor;
+ outline-offset: 3px;
+ }
+ }
+
+ .sponsor-directory__logo-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 150px;
+ margin: 0 0 22px;
+ }
+
+ .sponsor-directory__logo {
+ display: block;
+ width: auto;
+ max-width: 100%;
+ height: auto;
+ max-height: 130px;
+ object-fit: contain;
+ }
+
+ .sponsor-directory__card-content,
+ .sponsor-directory__details {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ }
+
+ .sponsor-directory__name {
+ margin-bottom: 4px;
+ font-size: 22px;
+ font-weight: 700;
+ line-height: 1.25;
+ }
+
+ .sponsor-directory__details {
+ font-size: 15px;
+ line-height: 1.45;
+ }
+
+ [hidden] {
+ display: none !important;
+ }
+
+ @media (max-width: 600px) {
+
+ .sponsor-directory__filters {
+ grid-template-columns: 1fr;
+ }
+
+ .sponsor-directory__reset {
+ justify-self: start;
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+
+ .sponsor-directory__card-button {
+ transition: none;
+ }
+ }
+}
+
+.sponsor-directory-modal {
+ position: fixed;
+ z-index: 667;
+ top: 50%;
+ left: 50%;
+ width: min(calc(100% - 32px), 800px);
+ max-height: calc(100vh - 32px);
+ padding: 0;
+ overflow: auto;
+ transform: translate(-50%, -50%);
+ border: 0;
+ background: #fff;
+ color: #222;
+
+ &__wrapper {
+ position: relative;
+ }
+
+ &-close__wrapper {
+ position: sticky;
+ z-index: 2;
+ top: 0;
+ display: flex;
+ justify-content: flex-end;
+ height: 0;
+ }
+
+ &-close {
+ position: relative;
+ width: 50px;
+ height: 50px;
+ padding: 0;
+ border: 0;
+ background: #111;
+ color: #fff;
+ cursor: pointer;
+
+ &::before,
+ &::after {
+ position: absolute;
+ top: 24px;
+ left: 13px;
+ width: 24px;
+ height: 2px;
+ background: currentcolor;
+ content: "";
+ }
+
+ &::before {
+ transform: rotate(45deg);
+ }
+
+ &::after {
+ transform: rotate(-45deg);
+ }
+
+ &:focus-visible {
+ outline: 3px solid #fff;
+ outline-offset: -6px;
+ }
+ }
+
+ &-overlay {
+ position: fixed;
+ z-index: 666;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background: rgb(0 0 0 / 70%);
+ backdrop-filter: blur(4px);
+ cursor: pointer;
+ }
+
+ &__inner {
+ display: grid;
+ grid-template-columns: minmax(180px, 30%) 1fr;
+ gap: 36px;
+ padding: 32px 48px 48px;
+ }
+
+ &__logo-wrapper {
+ margin: 0;
+ }
+
+ &__logo {
+ width: auto;
+ max-width: 100%;
+ height: auto;
+ max-height: 180px;
+ object-fit: contain;
+ }
+
+ &-title {
+ margin: 0;
+ padding: 56px 48px 0;
+ font-size: 32px;
+ line-height: 1.2;
+ }
+
+ &__details {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ }
+
+ &__description {
+ margin-top: 24px;
+ line-height: 1.6;
+ }
+
+ &__website {
+ display: inline-block;
+ margin-top: 18px;
+ font-weight: 700;
+ }
+
+ @media (max-width: 640px) {
+
+ width: 100%;
+ max-height: 100vh;
+
+ &__inner {
+ grid-template-columns: 1fr;
+ gap: 24px;
+ padding: 24px 24px 32px;
+ }
+
+ &-title {
+ padding: 64px 24px 0;
+ }
+
+ &__logo-wrapper {
+ max-width: 240px;
+ }
+ }
+}
+
+.modal-hide {
+ display: none;
+}
+
+.no-scroll {
+ overflow: hidden;
+}
diff --git a/web/wp-content/plugins/sponsor-directory-block/src/view.js b/web/wp-content/plugins/sponsor-directory-block/src/view.js
new file mode 100644
index 00000000..ee8cefeb
--- /dev/null
+++ b/web/wp-content/plugins/sponsor-directory-block/src/view.js
@@ -0,0 +1,76 @@
+import { sponsorMatchesFilters } from './filter';
+
+const initializeDirectory = ( directory ) => {
+ const categorySelect = directory.querySelector( '[data-category-filter]' );
+ const levelSelect = directory.querySelector( '[data-level-filter]' );
+ const resetButton = directory.querySelector( '[data-reset-filters]' );
+ const count = directory.querySelector( '[data-result-count]' );
+ const empty = directory.querySelector( '[data-no-results]' );
+
+ if (
+ ! categorySelect ||
+ ! levelSelect ||
+ ! resetButton ||
+ ! count ||
+ ! empty
+ ) {
+ return;
+ }
+
+ const cards = Array.from(
+ directory.querySelectorAll( '[data-sponsor-card]' )
+ );
+
+ const update = () => {
+ let visibleCount = 0;
+
+ cards.forEach( ( card ) => {
+ let categories = [];
+ try {
+ categories = JSON.parse( card.dataset.categories || '[]' );
+ } catch ( error ) {
+ categories = [];
+ }
+
+ const matches = sponsorMatchesFilters(
+ {
+ level: card.dataset.level || '',
+ categories,
+ },
+ categorySelect.value,
+ levelSelect.value
+ );
+
+ card.hidden = ! matches;
+ if ( matches ) {
+ visibleCount += 1;
+ }
+ } );
+
+ count.textContent =
+ visibleCount === 1 ? '1 sponsor' : `${ visibleCount } sponsors`;
+ empty.hidden = visibleCount !== 0;
+ resetButton.disabled = ! categorySelect.value && ! levelSelect.value;
+ };
+
+ categorySelect.addEventListener( 'change', update );
+ levelSelect.addEventListener( 'change', update );
+ resetButton.addEventListener( 'click', () => {
+ categorySelect.value = '';
+ levelSelect.value = '';
+ update();
+ } );
+ update();
+};
+
+const initializeDirectories = () => {
+ document
+ .querySelectorAll( '[data-sponsor-directory]' )
+ .forEach( initializeDirectory );
+};
+
+if ( document.readyState === 'loading' ) {
+ document.addEventListener( 'DOMContentLoaded', initializeDirectories );
+} else {
+ initializeDirectories();
+}