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 + + + + Sponsors + + + + 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 + + + + + + + TechCorp Industries + + + + + + Green Energy Solutions + + + + + + Creative Design Studio + + + + + + InnovateLab + + + + + + Patricia Johnson + + + + + + + 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. + + + + + Gold Sponsor - Leading technology solutions provider + + + + + Silver Sponsor - Sustainable energy advocate + + + + + Bronze Sponsor - Award-winning design agency + + + + + Startup Sponsor - Fostering innovation in the community + + + + + Individual Sponsor - Community advocate and supporter + + + + + 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 @@ - - -