Appearance
My Account Tabs
The customer account of a Lynx store is one frame shared by every account page: the My Account component (default_account) draws the sidebar with the account tabs and holds one section component (group account_section) in its slot. The built-in tabs (My Account, My Orders, Address Book, Account Information, My Downloadable Products, Newsletter Subscriptions, My Wish List, My Product Reviews, Stored Payment Methods) are each a section in that frame.
Adding a tab therefore takes four pieces:
| Piece | What it does |
|---|---|
A sidebar entry in the tabs pool of OOCS\LynxCustomer\Model\Customer\AccountTabs | The link in the account sidebar, its label, URL and position |
| A section component with a provider | The content of the tab, from your module's services |
A PreviewPagebuilder page entry with sample_data | Lets merchants design the page in the editor, previewed with sample rows instead of a signed-in customer |
| A starting layout | The frame, told which tab is current, with your section in its slot, so the page has content before anyone opens the editor |
The controller of the page stays your own: a controller that extends Magento\Customer\Controller\AbstractAccount keeps redirecting guests to sign in.
Claude Code
The Lynx Claude Code skills can add a My Account tab for you, following this page. See Working with Claude Code.
Example: My Product Questions
The hypothetical extension Vendor_ProductQuestions has a My Product Questions tab at /productquestions/my/index, listing the questions the customer asked. Its compatibility module Vendor_ProductQuestionsLynx adds the Lynx side; how that module is set up is on Making a Luma Extension Compatible.
1. The page config
Page config, view/frontend/layout/lynx_productquestions_my_index.xml:
xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<update handle="lynx_no_cacheable"/>
<head>
<title>My Product Questions</title>
<meta name="robots" content="NOINDEX,NOFOLLOW"/>
</head>
<body>
<referenceBlock name="lynx.inertia">
<arguments>
<argument name="ssr" xsi:type="boolean">false</argument>
<argument name="encrypt_history" xsi:type="boolean">true</argument>
</arguments>
</referenceBlock>
</body>
</page>2. The sidebar entry
The sidebar entry, in etc/frontend/di.xml. The tabs pool of AccountTabs is declared in the frontend area, so contributions go in the same scope:
xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="OOCS\LynxCustomer\Model\Customer\AccountTabs">
<arguments>
<argument name="tabs" xsi:type="array">
<item name="product_questions" xsi:type="array">
<item name="label" xsi:type="string">My Product Questions</item>
<item name="route" xsi:type="string">productquestions/my</item>
<item name="sort_order" xsi:type="number">85</item>
</item>
</argument>
</arguments>
</type>
</config>The item name (product_questions) is the tab key. The frame highlights the tab whose route is the longest prefix of the current page's route (module/controller/action), so productquestions/my covers productquestions/my/index and productquestions/my/view. A page under a different path, such as a detail page, belongs to the tab whose match item covers it: <item name="match" xsi:type="string">productquestions</item> (the built-in Orders tab matches sales/order for order detail, invoices, shipments and refunds). The Luma customer_account_navigation link is not read on Lynx, so this entry replaces it.
3. The declaration and the editor page
The section component and the frame, in etc/components/AccountQuestions.graphqls. The declaration lists every key the provider returns; the server drops the others, and the components take their props type, Lynx.VendorAccountProductQuestions, from it. extend type Account lets merchants use the My Account frame (default_account, declared in module-lynx-customer/etc/components/MyAccount.graphqls) on the new page. An argument of @component in an extend type replaces the old value, so copy the current availableOn list from that file and append your path:
graphql
"The questions the signed-in customer asked, with their answers"
type VendorAccountProductQuestions @component(
code: "vendor_account_product_questions"
template: "Vendor_ProductQuestionsLynx::Components/AccountQuestions"
provider: "Vendor\\ProductQuestionsLynx\\Provider\\AccountQuestions"
label: "My Product Questions"
# The group the My Account slot accepts
group: "account_section"
icon: "ph-question"
zones: ["content"]
availableOn: ["productquestions_my_index"]
) {
items: [VendorAccountQuestion!]! @optional
"Every string the section shows, translated by the provider"
labels: StringMap! @optional
}
type VendorAccountQuestion {
id: Int!
product: String!
url: String!
question: String!
answer: String!
date: String!
}
extend type Account @component(
availableOn: ["customer_account_index", "customer_account_edit", "customer_address_index", "customer_address_form", "sales_order_history", "sales_order_view", "sales_order_invoice", "sales_order_shipment", "sales_order_creditmemo", "downloadable_customer_products", "newsletter_manage_index", "wishlist_index_index", "vault_cards_listaction", "review_customer_index", "review_customer_view", "productquestions_my_index"]
)Your module must load after OOCS_LynxCustomer (<sequence> in etc/module.xml), and after any other installed module that extends the same list, since the module later in the sequence wins.
The editor page, in the global etc/di.xml:
xml
<type name="OOCS\LynxPageBuilder\Model\PreviewPagebuilder">
<arguments>
<argument name="pages" xsi:type="array">
<item name="account_product_questions" xsi:type="array">
<item name="sort_order" xsi:type="number">70</item>
<item name="label" xsi:type="string">My product questions page</item>
<item name="path" xsi:type="string">productquestions_my_index</item>
<item name="route" xsi:type="string">productquestions/my/index</item>
<item name="group" xsi:type="string">Account</item>
<!-- The controller would send the editor to the sign-in page -->
<item name="sample_data" xsi:type="boolean">true</item>
</item>
</argument>
</arguments>
</type>4. The provider
The provider. On a sample_data page the editor preview skips the controller, so the provider also implements SampleProviderInterface and shows sample rows:
php
<?php
declare(strict_types=1);
namespace Vendor\ProductQuestionsLynx\Provider;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
use OOCS\LynxComponent\Api\SampleProviderInterface;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
use Vendor\ProductQuestions\Api\QuestionRepositoryInterface;
class AccountQuestions extends AbstractComponentProvider implements SampleProviderInterface
{
public function __construct(
private readonly CustomerSession $customerSession,
private readonly QuestionRepositoryInterface $questionRepository,
private readonly TimezoneInterface $timezone,
) {
}
/**
* The signed-in customer's questions
*
* @param array $props
* @return array
*/
public function resolve(array $props): array
{
$props['labels'] = $this->getLabels();
// The controller redirects guests; the page is not cached, so the session is safe to read
if (!$this->customerSession->isLoggedIn()) {
return $props;
}
$props['items'] = array_map(fn ($question) => [
'id' => (int) $question->getId(),
'product' => (string) $question->getProductName(),
'url' => (string) $question->getProductUrl(),
'question' => (string) $question->getQuestion(),
'answer' => (string) $question->getAnswer(),
'date' => $this->timezone->formatDate($question->getCreatedAt(), \IntlDateFormatter::MEDIUM),
], $this->questionRepository->getListForCustomer((int) $this->customerSession->getCustomerId()));
return $props;
}
/**
* Sample rows for the editor preview
*
* @param array $resolved
* @param array $props
* @return array
*/
public function resolveSample(array $resolved, array $props): array
{
$resolved['items'] = [[
'id' => 1,
'product' => 'Sample Product',
'url' => '#',
'question' => 'Is this machine washable?',
'answer' => 'Yes, at 30 degrees.',
'date' => $this->timezone->formatDate(null, \IntlDateFormatter::MEDIUM),
]];
return $resolved;
}
/**
* @return array<string, string>
*/
private function getLabels(): array
{
return [
'title' => (string) __('My Product Questions'),
'empty' => (string) __('You have not asked any questions yet.'),
'answer' => (string) __('Answer'),
'waiting' => (string) __('Awaiting moderation'),
];
}
}5. The component
The component is a plain list; it sits inside the frame, so its root is w-full with no outer spacing:
vue
<!-- AccountQuestions.vue -->
<script setup lang="ts">
import { Link } from '@inertiajs/vue3'
withDefaults(defineProps<Lynx.VendorAccountProductQuestions>(), {
items: () => [],
labels: () => ({}),
})
</script>
<template>
<div class="grid w-full gap-6">
<h1 class="text-2xl font-semibold text-base-content">{{ labels.title }}</h1>
<p v-if="!items.length" class="text-base-content/70">{{ labels.empty }}</p>
<ul v-else class="divide-y divide-base-300">
<li v-for="item in items" :key="item.id" class="grid gap-1 py-4">
<Link :href="item.url" class="text-sm text-link">{{ item.product }} · {{ item.date }}</Link>
<p class="font-semibold text-base-content">{{ item.question }}</p>
<p class="text-base-content/70">
{{ item.answer ? `${labels.answer}: ${item.answer}` : labels.waiting }}
</p>
</li>
</ul>
</div>
</template>6. The starting layout
The starting layout: the My Account frame, told which tab is current, with the section in its slot. Written to the default design, which every merchant design inherits until they save that page themselves:
php
<?php
declare(strict_types=1);
namespace Vendor\ProductQuestionsLynx\Setup\Patch\Data;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use OOCS\LynxPageBuilder\Model\Design;
use OOCS\LynxPageBuilder\Setup\Patch\Data\CreateDefaultDesign;
class InstallAccountLayout implements DataPatchInterface
{
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup,
private readonly Json $json,
) {
}
public static function getDependencies(): array
{
return [CreateDefaultDesign::class];
}
public function getAliases(): array
{
return [];
}
public function apply(): self
{
$nodes = [[
'name' => 'default_group',
'component' => 'OOCS_LynxThemeComponent/Components/Group',
'label' => 'Group',
'visible' => true,
'props' => ['layout' => 'constrained', 'width' => 'site', 'padding_y' => 'sm', 'padding_x' => 'none'],
'slots' => ['default' => [[
'name' => 'default_account',
'component' => 'OOCS_LynxCustomer/Components/MyAccount',
'label' => 'My Account',
'visible' => true,
'props' => [],
'slots' => ['default' => [[
'name' => 'vendor_account_product_questions',
'component' => 'Vendor_ProductQuestionsLynx/Components/AccountQuestions',
'label' => 'My Product Questions',
'visible' => true,
'props' => [],
]]],
]]],
]];
$connection = $this->moduleDataSetup->getConnection();
$table = $this->moduleDataSetup->getTable('lynx_pagebuilder_layout');
// Insert only: a row already under this path stays as it is
$saved = $connection->fetchOne(
$connection->select()->from($table, ['path'])
->where('design_id = ?', Design::DEFAULT_ID)
->where('path = ?', 'productquestions_my_index')
);
if (!$saved) {
$connection->insert($table, [
'design_id' => Design::DEFAULT_ID,
'path' => 'productquestions_my_index',
'value' => $this->json->serialize($nodes),
]);
}
return $this;
}
}