diff --git a/membership_group/data/membership_group_demo.xml b/membership_group/data/membership_group_demo.xml
index e4c2c83c..dcfc3e39 100644
--- a/membership_group/data/membership_group_demo.xml
+++ b/membership_group/data/membership_group_demo.xml
@@ -22,4 +22,20 @@
+
+ Board of Directors
+
+
+
+
+
+ Executive Committee
+
+
+
+ Advisory Board
+
+
diff --git a/membership_group/data/res_partner_demo.xml b/membership_group/data/res_partner_demo.xml
index 7cc2697f..7f88fa32 100644
--- a/membership_group/data/res_partner_demo.xml
+++ b/membership_group/data/res_partner_demo.xml
@@ -66,5 +66,153 @@
]"
/>
+
+
+
+ Dr. Sarah Mitchell
+
+
+
+
+
+ James Anderson
+
+
+
+
+
+ Maria Garcia
+
+
+
+
+
+ Robert Chen
+
+
+
+
+
+ Emily Watson
+
+
+
+
+
+ Michael Brown
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ David Thompson
+
+
+
+
+
+ Linda Martinez
+
+
+
+
+
+
+ Prof. William Turner
+
+
+
+
+
+ Jennifer Lee
+
+
+
+
+
+ Christopher Davis
+
+
+
diff --git a/website_membership_group/__init__.py b/website_membership_group/__init__.py
index 91c5580f..72d3ea60 100644
--- a/website_membership_group/__init__.py
+++ b/website_membership_group/__init__.py
@@ -1,2 +1 @@
-from . import controllers
-from . import models
+from . import controllers, models
diff --git a/website_membership_group/__manifest__.py b/website_membership_group/__manifest__.py
index dbb5b53c..64216693 100644
--- a/website_membership_group/__manifest__.py
+++ b/website_membership_group/__manifest__.py
@@ -4,7 +4,7 @@
{
"name": "Website Membership Group",
"category": "Membership",
- "version": "18.0.1.0.0",
+ "version": "18.0.1.0.3",
"author": "Onestein",
"license": "AGPL-3",
"website": "https://www.onestein.nl",
@@ -13,7 +13,21 @@
"website_membership",
],
"data": [
+ "security/website_membership_group_security.xml",
"views/membership_group_view.xml",
"templates/website.xml",
+ "views/snippets/snippet_options.xml",
],
+ "demo": [
+ "demo/website_membership_group_demo.xml",
+ ],
+ "assets": {
+ "web.assets_frontend": [
+ "website_membership_group/static/src/scss/membership_group.scss",
+ "website_membership_group/static/src/js/membership_group_frontend.esm.js",
+ ],
+ "website.assets_wysiwyg": [
+ "website_membership_group/static/src/js/membership_group_options.esm.js",
+ ],
+ },
}
diff --git a/website_membership_group/controllers/main.py b/website_membership_group/controllers/main.py
index 3a73c91f..7a874c5b 100644
--- a/website_membership_group/controllers/main.py
+++ b/website_membership_group/controllers/main.py
@@ -1,6 +1,10 @@
+import logging
+
from odoo import http
from odoo.http import request
+_logger = logging.getLogger(__name__)
+
class MembershipGroupController(http.Controller):
def _membership_group_page_render_vals(self, membership_group):
@@ -29,9 +33,18 @@ def display_membership_group_page(self, membership_group):
if not membership_group_sudo.is_published and not is_website_designer:
return request.not_found()
- if membership_group_sudo.page_id:
- return request.redirect(membership_group_sudo.page_id.url)
-
vals = self._membership_group_page_render_vals(membership_group_sudo)
- return request.render("website_membership_group.membership_group_page", vals)
+ view_key = membership_group_sudo.page_id.view_id.key
+ if not view_key:
+ return request.not_found()
+
+ try:
+ return request.render(view_key, vals)
+ except ValueError:
+ return request.not_found()
+ except Exception:
+ _logger.exception(
+ "Unexpected error rendering membership group page for view %s", view_key
+ )
+ return request.not_found()
diff --git a/website_membership_group/demo/website_membership_group_demo.xml b/website_membership_group/demo/website_membership_group_demo.xml
new file mode 100644
index 00000000..c47e5630
--- /dev/null
+++ b/website_membership_group/demo/website_membership_group_demo.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ President of the Board with 15 years of experience in non-profit leadership.
+
+
+
+
+ Vice President focusing on strategic partnerships and community outreach.
+
+
+
+
+ Secretary with expertise in governance and compliance.
+
+
+
+
+ Treasurer with 20 years of financial management experience.
+
+
+
+
+ Board member specializing in marketing and communications.
+
+
+
+
+ Board member with background in technology and innovation.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Executive Chair leading organizational strategy
+
+
+
+
+ Program Coordinator managing daily operations
+
+
+
+
+ Academic advisor with expertise in public policy
+
+
+
+
+ Legal advisor specializing in non-profit law
+
+
+
+
+ Financial advisor with expertise in investment management
+
+
diff --git a/website_membership_group/models/membership_group.py b/website_membership_group/models/membership_group.py
index 55a3f300..d063a874 100644
--- a/website_membership_group/models/membership_group.py
+++ b/website_membership_group/models/membership_group.py
@@ -7,7 +7,7 @@
class MembershipGroup(models.Model):
_name = "membership.group"
- _inherit = ["membership.group", "website.published.mixin"]
+ _inherit = ["membership.group", "website.published.mixin", "website.seo.metadata"]
website_top = fields.Html(strip_style=True, translate=html_translate)
website_bottom = fields.Html(strip_style=True, translate=html_translate)
@@ -15,6 +15,22 @@ class MembershipGroup(models.Model):
icon = fields.Image()
page_id = fields.Many2one(comodel_name="website.page")
website_url = fields.Char(compute="_compute_website_url", store=True)
+ website_snippet_wrap_classes = fields.Char(
+ string="Wrap Classes",
+ default="mg_show_image mg_show_top mg_show_bottom mg_show_committee mg_show_team",
+ help="Classes for the #wrap element (visibility options)",
+ )
+ website_snippet_members_classes = fields.Char(
+ string="Members Row Classes",
+ default="mg_layout_grid mg_show_committee_desc mg_show_team_desc",
+ help="Classes for the #mg_members_row element (layout and description options)",
+ )
+ website_committee_col_classes = fields.Char(
+ string="Committee Column Classes", default="col-lg-4"
+ )
+ website_team_col_classes = fields.Char(
+ string="Team Column Classes", default="col-lg-8"
+ )
@api.depends("page_id", "page_id.is_published")
def _compute_website_url(self):
@@ -26,3 +42,51 @@ def _compute_website_url(self):
and membership_group.page_id.url
or "/members/group/%s" % slug(membership_group)
)
+
+ def _create_membership_page(self):
+ """Create a unique website.page for this membership group."""
+ self.ensure_one()
+ if self.page_id:
+ return self.page_id
+
+ slug = self.env["ir.http"]._slug
+ page_url = "/members/group/%s" % slug(self)
+ view_key = "website_membership_group.membership_group_page_custom_%s" % self.id
+
+ # Create the view with unique oe_structure IDs
+ view = self.env["ir.ui.view"].create(
+ {
+ "name": "Membership Group: %s" % self.name,
+ "key": view_key,
+ "type": "qweb",
+ "arch": self._get_page_arch(),
+ }
+ )
+
+ # Create the website page
+ page = self.env["website.page"].create(
+ {
+ "url": page_url,
+ "website_published": True,
+ "view_id": view.id,
+ }
+ )
+
+ self.page_id = page
+ return page
+
+ def _get_page_arch(self):
+ """Return the raw QWeb arch from the base template."""
+ view = self.env.ref("website_membership_group.membership_group_page_base")
+ return view.arch
+
+ def write(self, vals):
+ """Auto-create page on publish, sync publish status on both transitions."""
+ res = super().write(vals)
+ if "is_published" in vals:
+ for group in self:
+ if group.is_published and not group.page_id:
+ group._create_membership_page()
+ if group.page_id and group.page_id.is_published != group.is_published:
+ group.page_id.is_published = group.is_published
+ return res
diff --git a/website_membership_group/security/website_membership_group_security.xml b/website_membership_group/security/website_membership_group_security.xml
new file mode 100644
index 00000000..441b2d63
--- /dev/null
+++ b/website_membership_group/security/website_membership_group_security.xml
@@ -0,0 +1,9 @@
+
+
+
+ Membership Group: website published only
+
+ [('is_published', '=', True)]
+
+
+
\ No newline at end of file
diff --git a/website_membership_group/static/src/js/membership_group_frontend.esm.js b/website_membership_group/static/src/js/membership_group_frontend.esm.js
new file mode 100644
index 00000000..64a8086f
--- /dev/null
+++ b/website_membership_group/static/src/js/membership_group_frontend.esm.js
@@ -0,0 +1,38 @@
+import publicWidget from "@web/legacy/js/public/public_widget";
+
+publicWidget.registry.MembershipGroupCollapsible = publicWidget.Widget.extend({
+ selector: '#mg_members_row',
+
+ start: function () {
+ this.setupCollapsibleButtons();
+ return this._super.apply(this, arguments);
+ },
+
+ setupCollapsibleButtons: function () {
+ const buttons = this.el.querySelectorAll('.mg_collapse_btn');
+ buttons.forEach(button => {
+ button.onclick = (e) => {
+ e.preventDefault();
+ this.toggleDescription(button);
+ };
+ });
+ },
+
+ toggleDescription: function (button) {
+ const descriptionId = button.getAttribute('aria-controls');
+ // eslint-disable-next-line no-undef
+ const description = document.getElementById(descriptionId);
+
+ if (!description) return;
+
+ const isExpanded = button.getAttribute('aria-expanded') === 'true';
+
+ if (isExpanded) {
+ description.classList.add('d-none');
+ button.setAttribute('aria-expanded', 'false');
+ } else {
+ description.classList.remove('d-none');
+ button.setAttribute('aria-expanded', 'true');
+ }
+ },
+});
diff --git a/website_membership_group/static/src/js/membership_group_options.esm.js b/website_membership_group/static/src/js/membership_group_options.esm.js
new file mode 100644
index 00000000..a0bb612e
--- /dev/null
+++ b/website_membership_group/static/src/js/membership_group_options.esm.js
@@ -0,0 +1,130 @@
+import options from "@web_editor/js/editor/snippets.options";
+import { rpc } from "@web/core/network/rpc";
+/* global MutationObserver */
+/**
+ * Helper to filter for module-specific option classes.
+ * Excludes base structural classes to prevent duplication in templates.
+ */
+const filterMgClasses = (classList, excludeClass) => {
+ return classList.split(/\s+/)
+ .filter(cls => cls.startsWith('mg_') && cls !== excludeClass)
+ .join(' ');
+};
+options.registry.MembershipGroupPage = options.Class.extend({
+ /**
+ * @override
+ */
+ // eslint-disable-next-line no-unused-vars
+ selectClass: function (previewMode, widgetValue, params) {
+ this._super(...arguments);
+ // Only save to database on actual click (not during hover preview)
+ if (!previewMode) {
+ this._saveToDatabase();
+ }
+ },
+
+ /**
+ * Save the current class list to the database
+ */
+ _saveToDatabase: function () {
+ const id = parseInt(this.$target[0].dataset.oeId,10);
+ // Filter to keep only option classes, excluding 'mg_wrap'
+ const classes = filterMgClasses(this.$target[0].className, 'mg_wrap');
+ if (this.lastSavedClasses !== classes) {
+ this.lastSavedClasses = classes;
+ if (id && classes) {
+ return rpc('/web/dataset/call_kw', {
+ model: "membership.group",
+ method: 'write',
+ args: [[id], {'website_snippet_wrap_classes': classes}],
+ kwargs: {},
+ });
+ }
+ }
+ },
+});
+
+options.registry.MemberLayoutOpts = options.Class.extend({
+ /**
+ * @override
+ * Triggered for Layout and Description toggles.
+ */
+ // eslint-disable-next-line no-unused-vars
+ selectClass: function (previewMode, value, $li) {
+ this._super(...arguments);
+ if (!previewMode) {
+ this._saveToDatabase();
+ }
+ },
+
+ _saveToDatabase: function () {
+ const id = parseInt(this.$target[0].dataset.oeId,10);
+ // Filter to keep only option classes, excluding 'mg_members_row'
+ const classes = filterMgClasses(this.$target[0].className, 'mg_members_row');
+
+ if (this.lastSavedClasses !== classes) {
+ this.lastSavedClasses = classes;
+ if (id && classes) {
+ return rpc('/web/dataset/call_kw', {
+ model: "membership.group",
+ method: 'write',
+ args: [[id], {'website_snippet_members_classes': classes}],
+ kwargs: {},
+ });
+ }
+ }
+ },
+});
+
+options.registry.MemberColOpts = options.Class.extend({
+ start: function () {
+ const self = this;
+ this._super(...arguments);
+
+ this.observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if (mutation.attributeName === "class") {
+ self._saveToDatabase();
+ }
+ });
+ });
+
+ this.observer.observe(this.$target[0], {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ },
+
+ destroy: function () {
+ if (this.observer) {
+ this.observer.disconnect();
+ }
+ this._super(...arguments);
+ },
+
+ _saveToDatabase: function () {
+ const $row = this.$target.closest('#mg_members_row');
+ const id = parseInt($row[0]?.dataset.oeId, 10);
+ // Persist only user-configurable classes; exclude structural mg_*_col classes
+ const structuralClasses = ['mg_committee_col', 'mg_team_col'];
+ const classes = this.$target[0].className.split(/\s+/)
+ .filter(cls => !structuralClasses.includes(cls) && (cls.startsWith('mg_') || cls.startsWith('col-')))
+ .join(' ');
+
+ const field = this.$target.hasClass('mg_committee_col')
+ ? 'website_committee_col_classes'
+ : 'website_team_col_classes';
+
+ if (id && field && classes) {
+ if (this.lastSavedColClasses !== classes) {
+ this.lastSavedColClasses = classes;
+ return rpc('/web/dataset/call_kw', {
+ model: "membership.group",
+ method: 'write',
+ args: [[id], {[field]: classes}],
+ kwargs: {},
+ });
+ }
+ }
+ },
+});
diff --git a/website_membership_group/static/src/scss/membership_group.scss b/website_membership_group/static/src/scss/membership_group.scss
new file mode 100644
index 00000000..6722477c
--- /dev/null
+++ b/website_membership_group/static/src/scss/membership_group.scss
@@ -0,0 +1,183 @@
+/* =======================================================================
+ VISIBILITY TOGGLES (LAYOUT)
+ Controlled via classes applied to the #wrap element
+ ======================================================================= */
+.mg_wrap {
+ // Default: Hide all optional sections
+ .mg_image_col,
+ .mg_top_block,
+ .mg_bottom_block,
+ .mg_committee_col,
+ .mg_team_col {
+ display: none;
+ }
+
+ // Toggle display based on snippet option classes
+ &.mg_show_image .mg_image_col { display: block; }
+ &.mg_show_top .mg_top_block { display: block; }
+ &.mg_show_bottom .mg_bottom_block { display: block; }
+ &.mg_show_committee .mg_committee_col { display: block; }
+ &.mg_show_team .mg_team_col { display: block; }
+}
+
+/* =======================================================================
+ MEMBER DESCRIPTION VISIBILITY & COLLAPSIBLE SYSTEM
+ Tied to Committee Desc / Team Desc snippet options
+ ======================================================================= */
+
+/* Default: Hide descriptions and buttons for both sections */
+.mg_member_description,
+.mg_collapse_btn {
+ display: none !important;
+}
+
+// Show descriptions if the respective section toggle is ON
+.mg_show_team_desc .mg_team_col .mg_member_description,
+.mg_show_committee_desc .mg_committee_col .mg_member_description {
+ display: block !important;
+}
+
+/* Hide descriptions if collapsible is active and closed */
+#mg_members_row.mg_collapse_desc .mg_member_description.d-none {
+ display: none !important;
+}
+
+/* Animation for expanding descriptions */
+#mg_members_row.mg_collapse_desc:not(.mg_layout_grid) .mg_member_description:not(.d-none) {
+ display: block !important;
+ animation: slideDown 0.3s ease-in-out;
+}
+
+/* Show buttons ONLY if BOTH Collapsible AND the respective Section Toggle are ON */
+#mg_members_row:not(.mg_layout_grid).mg_collapse_desc.mg_show_committee_desc .mg_collapse_btn,
+#mg_members_row:not(.mg_layout_grid).mg_collapse_desc.mg_show_team_desc .mg_collapse_btn {
+ display: inline-flex !important;
+ padding: 0.25rem 0.5rem;
+ color: #6c757d;
+ font-size: 0.875rem;
+ transition: transform 0.3s ease-in-out, color 0.2s ease-in-out;
+}
+
+#mg_members_row.mg_collapse_desc .mg_collapse_btn:hover {
+ color: $primary;
+}
+
+#mg_members_row.mg_collapse_desc .mg_collapse_btn[aria-expanded="true"] {
+ transform: rotate(180deg);
+}
+
+#mg_members_row.mg_collapse_desc .mg_collapse_btn[aria-expanded="true"] i {
+ color: $primary;
+}
+
+@keyframes slideDown {
+ from { opacity: 0; transform: translateY(-10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* =======================================================================
+ MEMBER LAYOUT VARIANTS
+ ======================================================================= */
+
+/* --- GRID LAYOUT --- */
+#mg_members_row.mg_layout_grid {
+ .mg_member_list {
+ gap: 0;
+ }
+
+ /* 1 Column for Committee Section in Grid */
+ .mg_committee_col .mg_member_item {
+ flex: 0 0 100%;
+ }
+
+ /* 2 Columns for Team Section in Grid */
+ .mg_team_col .mg_member_item {
+ flex: 0 0 50%;
+ }
+
+ /* Force hide description and collapse buttons in Grid Layout */
+ .mg_member_description,
+ .mg_collapse_btn {
+ display: none !important;
+ }
+}
+
+/* --- CARDS LAYOUT --- */
+#mg_members_row.mg_layout_cards {
+ .mg_member_list {
+ gap: 0.75rem;
+ padding: 0.75rem;
+ }
+
+ .mg_member_item {
+ flex: 1 1 100% !important;
+ width: 100%;
+ min-width: 0;
+ border: 1px solid $border-color !important;
+ border-radius: $border-radius !important;
+ background: #fff;
+ box-shadow: $box-shadow-sm;
+ padding: 1rem !important;
+ transition: box-shadow 0.15s ease-in-out;
+
+ &:hover {
+ box-shadow: $box-shadow;
+ }
+ }
+
+ .mg_member_name {
+ text-align: left;
+ }
+}
+
+/* --- AVATARS LAYOUT --- */
+#mg_members_row.mg_layout_avatars {
+ .mg_member_list {
+ gap: 1rem;
+ padding: 1rem;
+ }
+
+ .mg_member_item {
+ flex: 1 1 100% !important;
+ width: 100%;
+ min-width: 0;
+ border: none !important;
+ border-radius: 0 !important;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 0.5rem 0.25rem !important;
+ background: transparent;
+ }
+
+ .mg_member_name {
+ font-size: 0.8125rem;
+ text-align: center;
+ }
+
+ .mg_collapse_btn {
+ margin: 0 auto;
+ display: block;
+ text-align: center;
+ }
+}
+
+/* =======================================================================
+ STYLING & THEMING
+ ======================================================================= */
+.mg_divider {
+ border-color: $primary;
+}
+
+/* =======================================================================
+ RESPONSIVE BREAKPOINTS
+ ======================================================================= */
+@include media-breakpoint-down(sm) {
+ #mg_members_row.mg_layout_avatars .mg_member_item { flex: 1 1 calc(50% - 0.5rem); }
+}
+
+@include media-breakpoint-down(md) {
+ #mg_members_row.mg_layout_cards .mg_member_item { flex: 1 1 100%; }
+ #mg_members_row.mg_layout_avatars .mg_member_item { flex: 1 1 calc(50% - 1rem); }
+}
diff --git a/website_membership_group/templates/website.xml b/website_membership_group/templates/website.xml
index 022301af..247e9979 100644
--- a/website_membership_group/templates/website.xml
+++ b/website_membership_group/templates/website.xml
@@ -1,99 +1,125 @@
-
-
-
+
-
-