Multi-Market Implementation: Phase 6 - EasyAdmin Controllers¶
Status: Planning¶
Dependencies¶
Requires completed:
- Phase 1 (Entities) - Market, AffiliateProgram, BuyingOption entities
- Phase 2 (Migration) - Database schema ready
- Phase 3 (Services) - Validation services available
- Phase 4 (Cache/Repository) - Cache invalidation works
- Phase 5 (API Integration) - DTOs and services tested
Blocks:
- Phase 7 (Matrix UI) - Optional bean-centric buying options UI
📋 Todo Checklist¶
- [ ] Create MarketCrudController with shipping region selector
- [ ] Create AffiliateProgramCrudController with template validation
- [ ] Create BuyingOptionCrudController for manual overrides
- [ ] Update RoasterCrawlConfigCrudController with market and priority fields
- [ ] Update CrawlUrlCrudController with available field
- [ ] Add navigation menu section for Markets & Monetization
- [ ] Create form types for complex fields
- [ ] Add entity validation in controllers
- [ ] Create admin user guide documentation
- [ ] Test all CRUD operations
- [ ] Test form validation
🔍 Analysis & Investigation¶
Problem Statement¶
Admins need EasyAdmin interfaces to manage the multi-market system:
- Create and configure markets with countries
- Set up affiliate programs with Twig templates
- Create manual URL overrides (buying options)
- Assign roaster configs to markets with priorities
- Manage per-URL availability (mark URLs as available/unavailable)
Current Architecture¶
Existing EasyAdmin Controllers:
RoasterCrawlConfigCrudController- Has shipping region selector pattern- Various other CRUD controllers
Patterns to Reuse:
- Shipping region selector (from RoasterCrawlConfigCrudController)
- TomSelect for autocomplete relationships
- FormField::addFieldset() for field grouping
- Custom formatters for display
Target Architecture¶
Five new/updated controllers:
- MarketCrudController - Manage consumer markets
- AffiliateProgramCrudController - Manage affiliate programs
- BuyingOptionCrudController - Manage manual URL overrides
- RoasterCrawlConfigCrudController - Update with market/priority fields
- CrawlUrlCrudController - Update with available field for per-URL availability
Admin workflow:
- Create AffiliateProgram
- Create Market (assign countries + affiliate)
- Update RoasterCrawlConfigs (assign to market, set priority)
- Optionally create BuyingOptions for special cases
📝 Implementation Plan¶
Prerequisites¶
- Phase 1-5 completed
- EasyAdmin bundle configured
- Shipping configuration JavaScript available (
shipping-configuration.js)
Step-by-Step Implementation¶
Step 1: Create MarketCrudController¶
File: src/Controller/Admin/MarketCrudController.php
<?php
namespace App\Controller\Admin;
use App\Entity\Market;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
class MarketCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return Market::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Market')
->setEntityLabelInPlural('Markets')
->setSearchFields(['name', 'notes'])
->setDefaultSort(['name' => 'ASC'])
->setPageTitle('index', 'Markets & Consumer Regions')
->setPageTitle('new', 'Create New Market')
->setPageTitle('edit', 'Edit Market: %entity_label_singular%')
->setPageTitle('detail', '%entity_label_singular%');
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->setPermission(Action::DELETE, 'ROLE_ADMIN');
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')
->onlyOnDetail();
yield FormField::addFieldset('Basic Information');
yield TextField::new('name')
->setRequired(true)
->setHelp('Display name for this market (e.g., "United States", "European Union", "Germany - Amazon")');
yield BooleanField::new('isActive')
->setHelp('Enable or disable this market without deleting it');
yield FormField::addFieldset('Geographic Coverage')
->setHelp('Select which countries this market serves using the shipping region selector below');
// Reuse shipping region selector pattern from RoasterCrawlConfigCrudController
yield ChoiceField::new('shippingRegion', 'Shipping Region')
->setChoices([
'European Union' => 'EU',
'North America' => 'NA',
'Asia' => 'ASIA',
'Worldwide' => 'WORLDWIDE',
'Custom Selection' => 'CUSTOM',
])
->hideOnIndex()
->setFormTypeOption('mapped', false)
->setHelp('Quick select a region, then refine with exception countries below');
yield AssociationField::new('countries')
->setFormTypeOption('by_reference', false) // CRITICAL for ManyToMany
->setFormTypeOption('multiple', true)
->setRequired(true)
->hideOnIndex()
->setHelp('Countries where this market serves customers (at least one required)');
// Display-only field for index/detail
yield CollectionField::new('countries')
->onlyOnIndex()
->formatValue(function ($value, Market $market) {
$countries = $market->getCountries()->toArray();
if (count($countries) > 5) {
$first5 = array_slice($countries, 0, 5);
$names = array_map(fn($c) => $c->getName(), $first5);
return multi-market-06-easyadmin.mdimplode(', ', $names) . sprintf(' (+%d more)', count($countries) - 5);
}
$names = array_map(fn($c) => $c->getName(), $countries);
return implode(', ', $names);
});
yield FormField::addFieldset('Monetization');
yield AssociationField::new('affiliateProgram')
->setHelp('Affiliate program for monetizing purchases in this market (optional)')
->autocomplete();
yield FormField::addFieldset('Notes');
yield TextareaField::new('notes')
->hideOnIndex()
->setHelp('Internal notes about this market (not shown to customers)');
// Display metadata
if ($pageName === Crud::PAGE_DETAIL) {
yield FormField::addFieldset('System Information');
yield TextField::new('createdAt')
->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
yield TextField::new('updatedAt')
->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
}
}
}
JavaScript Asset: Reuse shipping-configuration.js from RoasterCrawlConfigCrudController for the shipping region
selector.
Step 2: Create AffiliateProgramCrudController¶
File: src/Controller/Admin/AffiliateProgramCrudController.php
<?php
namespace App\Controller\Admin;
use App\Entity\AffiliateProgram;
use App\Enum\AffiliateProvider;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CodeEditorField;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
class AffiliateProgramCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return AffiliateProgram::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Affiliate Program')
->setEntityLabelInPlural('Affiliate Programs')
->setSearchFields(['name', 'affiliateId', 'notes'])
->setDefaultSort(['name' => 'ASC'])
->setPageTitle('index', 'Affiliate Programs')
->setPageTitle('new', 'Create New Affiliate Program')
->setPageTitle('edit', 'Edit Affiliate Program: %entity_label_singular%')
->setPageTitle('detail', '%entity_label_singular%');
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->setPermission(Action::DELETE, 'ROLE_ADMIN');
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')
->onlyOnDetail();
yield FormField::addFieldset('Basic Configuration');
yield TextField::new('name')
->setRequired(true)
->setHelp('Display name (e.g., "Impact US", "Amazon Associates CA")');
yield ChoiceField::new('provider')
->setChoices([
'Impact' => AffiliateProvider::IMPACT->value,
'AWIN' => AffiliateProvider::AWIN->value,
'Partnerize' => AffiliateProvider::PARTNERIZE->value,
'Amazon Associates' => AffiliateProvider::AMAZON_ASSOCIATES->value,
'Custom' => AffiliateProvider::CUSTOM->value,
])
->setRequired(true)
->setHelp('Affiliate network provider');
yield TextField::new('affiliateId')
->setRequired(true)
->setHelp('Your affiliate ID / tag / account identifier');
yield BooleanField::new('isActive')
->setHelp('Enable or disable this program without deleting it');
yield FormField::addFieldset('Advanced Configuration')
->setHelp('⚠️ Advanced users only. Leave empty to use hard-coded patterns for standard providers.');
yield CodeEditorField::new('urlPattern', 'URL Pattern (Twig Template)')
->setLanguage('twig')
->hideOnIndex()
->setHelp('Twig template for URL transformation. Required for CUSTOM provider. Available variables: {{ url }}, {{ affiliateId }}, and any from parameters JSON below.')
->setFormTypeOption('attr', ['rows' => 5]);
yield CodeEditorField::new('parameters', 'Parameters (JSON)')
->setLanguage('json')
->hideOnIndex()
->setHelp('Additional template variables as JSON object (e.g., {"awinmid": "12345"}). Reserved keys: url, affiliateId.')
->setFormTypeOption('attr', ['rows' => 5]);
yield FormField::addFieldset('Notes');
yield TextareaField::new('notes')
->hideOnIndex()
->setHelp('Internal notes about this program');
// Display markets using this program on detail page
if ($pageName === Crud::PAGE_DETAIL) {
yield FormField::addFieldset('Markets Using This Program');
yield TextField::new('markets')
->formatValue(function ($value, AffiliateProgram $program) {
$markets = $program->getMarkets()->toArray();
if (empty($markets)) {
return 'No markets assigned';
}
$names = array_map(fn($m) => $m->getName(), $markets);
return implode(', ', $names);
});
}
// Display metadata
if ($pageName === Crud::PAGE_DETAIL) {
yield FormField::addFieldset('System Information');
yield TextField::new('createdAt')
->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
yield TextField::new('updatedAt')
->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
}
}
}
Validation Help Text Examples:
Add help text showing example patterns:
yield CodeEditorField::new('urlPattern', 'URL Pattern (Twig Template)')
->setHelp('
Examples:
- Amazon: {{ url }}{% if "?" in url %}&{% else %}?{% endif %}tag={{ affiliateId }}
- Custom: {{ url }}?ref={{ affiliateId }}&utm_source={{ source }}
Available variables: {{ url }}, {{ affiliateId }}, + any from parameters JSON
');
Step 3: Create BuyingOptionCrudController¶
File: src/Controller/Admin/BuyingOptionCrudController.php
<?php
namespace App\Controller\Admin;
use App\Entity\BuyingOption;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\FormField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Field\UrlField;
class BuyingOptionCrudController extends AbstractCrudController
{
public static function getEntityFqcn(): string
{
return BuyingOption::class;
}
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Buying Option')
->setEntityLabelInPlural('Buying Options (Manual Overrides)')
->setSearchFields(['coffeeBean.name', 'market.name', 'urlOverride'])
->setDefaultSort(['updatedAt' => 'DESC'])
->setPageTitle('index', 'Manual Buying Options')
->setPageTitle('new', 'Create Manual Buying Option')
->setPageTitle('edit', 'Edit Buying Option')
->setPageTitle('detail', '%entity_label_singular%')
->setHelp('index', 'Manual URL overrides for specific bean-market combinations. Only create for exceptions (Amazon links, special sellers, etc.).');
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->setPermission(Action::DELETE, 'ROLE_ADMIN');
}
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')
->onlyOnDetail();
yield FormField::addFieldset('Manual Override Configuration')
->setHelp('⚠️ Only create buying options when you need to override the automatic URL resolution. Most beans work automatically.');
yield AssociationField::new('coffeeBean')
->setRequired(true)
->autocomplete()
->setHelp('Select the coffee bean');
yield AssociationField::new('market')
->setRequired(true)
->autocomplete()
->setHelp('Select the market for this override');
yield UrlField::new('urlOverride')
->setRequired(true)
->setHelp('Manual URL override (e.g., Amazon product page, alternative seller). REQUIRED - leave empty to use automatic resolution.')
->setFormTypeOption('attr', ['placeholder' => 'https://example.com/product']);
yield FormField::addFieldset('Notes');
yield TextareaField::new('notes')
->hideOnIndex()
->setHelp('Why is this manual override needed? (e.g., "Available on Amazon DE", "Special arrangement with Roastmarket")');
// Display metadata
if ($pageName === Crud::PAGE_DETAIL) {
yield FormField::addFieldset('System Information');
yield TextField::new('createdAt')
->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
yield TextField::new('updatedAt')
->formatValue(fn($value) => $value?->format('Y-m-d H:i:s'));
}
}
}
Note: The bean-centric matrix UI (Phase 7) will provide a better interface for managing buying options. This CRUD controller is the backup/advanced interface.
Step 4: Update RoasterCrawlConfigCrudController¶
File: src/Controller/Admin/RoasterCrawlConfigCrudController.php (modify existing)
Add market and priority fields:
public function configureFields(string $pageName): iterable
{
// ... existing fields ...
yield FormField::addFieldset('Market Configuration')
->setHelp('Assign this config to a market and set its priority for URL selection');
yield AssociationField::new('market')
->autocomplete()
->setHelp('Consumer market this config serves (optional during transition, assign for multi-market support)');
yield NumberField::new('priority')
->setHelp('Priority for URL selection (0-1000). Higher priority wins when multiple configs serve the same country. Default: 50')
->setFormTypeOption('attr', [
'min' => 0,
'max' => 1000,
'step' => 10,
]);
// ... existing fields (shippingRegion, shipsTo, etc.) ...
yield FormField::addFieldset('Shipping Configuration')
->setHelp('⚠️ Note: During transition, both shipping and market countries are checked. Eventually, market countries will be primary.');
// ... existing shipping fields ...
}
Notes:
- Keep existing
shippingRegionandshipsTofields (serve different purpose) - Add new fieldset for market configuration
- Default priority = 50
Step 5: Update CrawlUrlCrudController¶
File: src/Controller/Admin/CrawlUrlCrudController.php (modify existing)
Add available field to allow admins to mark URLs as available/unavailable:
public function configureFields(string $pageName): iterable
{
yield IdField::new('id')->onlyOnDetail();
yield UrlField::new('url')
->formatValue(function ($value, $entity) {
return mb_strlen($value) > 100 ? mb_substr($value, 0, 100) . '...' : $value;
});
yield AssociationField::new('roasterCrawlConfig')
->setFormTypeOption('disabled', $pageName !== Crud::PAGE_NEW)
->hideOnIndex();
// NEW: Available field
yield BooleanField::new('available')
->setHelp('Marks whether this URL is available for purchase. Unavailable URLs will be filtered from API responses.')
->renderAsSwitch(true);
yield NumberField::new('contentConfidence')
->setHelp('Percentage confidence that this URL points to a coffee bean product')
->setNumDecimals(1);
yield BooleanField::new('success')
->renderAsSwitch(false);
// ... rest of existing fields ...
}
Filter Addition:
Add filter for available field:
public function configureFilters(Filters $filters): Filters
{
return $filters
->add(EntityFilter::new('roasterCrawlConfig'))
->add(BooleanFilter::new('available')) // NEW
->add(NumericFilter::new('contentConfidence'))
->add(BooleanFilter::new('success'))
->add(TextFilter::new('url'))
->add(DateTimeFilter::new('lastCrawled'))
->add(TextFilter::new('failReason'));
}
Notes:
- Display
availableas a switch for quick toggling - Add filter to quickly find unavailable URLs
- Help text explains the field's purpose in URL resolution
- Position after roasterCrawlConfig association for logical grouping
Step 6: Update DashboardController Navigation¶
File: src/Controller/Admin/DashboardController.php (modify existing)
Add new menu section:
public function configureMenuItems(): iterable
{
yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');
// ... existing menu items ...
// NEW: Markets & Monetization section
yield MenuItem::section('Markets & Monetization');
yield MenuItem::linkToCrud('Markets', 'fa fa-globe', Market::class);
yield MenuItem::linkToCrud('Affiliate Programs', 'fa fa-handshake', AffiliateProgram::class);
yield MenuItem::linkToCrud('Buying Options', 'fa fa-link', BuyingOption::class)
->setBadge('Manual Overrides', 'warning');
// ... existing menu items continue ...
}
Menu order suggestion:
- Dashboard
- Coffee Bean Management
- Markets & Monetization (NEW)
- Queue & Crawler Management
- System Configuration
Step 7: Create Admin User Guide¶
File: docs/admin-guide-multi-market.md
# Multi-Market System Admin Guide
## Overview
The multi-market system allows you to serve different URLs and affiliate programs based on visitor location.
## Setup Workflow
### 1. Create Affiliate Programs
Navigate to **Markets & Monetization → Affiliate Programs**
**Example: Amazon Associates US**
- Name: "Amazon Associates US"
- Provider: Amazon Associates
- Affiliate ID: "beanb-20"
- URL Pattern: Leave empty (uses hard-coded pattern)
**Example: Custom Direct Affiliate**
- Name: "Direct Partner Program"
- Provider: Custom
- Affiliate ID: "partner-id"
- URL Pattern: `{{ url }}?ref={{ affiliateId }}&utm_source=beanb`
### 2. Create Markets
Navigate to **Markets & Monetization → Markets**
**Example: US Direct**
- Name: "United States - Direct"
- Shipping Region: North America
- Countries: Select USA (or use region selector)
- Affiliate Program: Select "Amazon Associates US"
**Example: EU Market**
- Name: "European Union"
- Shipping Region: European Union
- Countries: Auto-populated, remove exceptions if needed
- Affiliate Program: Select your EU affiliate program
### 3. Assign Configs to Markets
Navigate to **Queue & Crawler Management → Roaster Crawl Configs**
For each config:
- Select Market (e.g., "US Direct")
- Set Priority (50 = default, higher = preferred)
**Priority Examples:**
- Roaster's main site: Priority 100
- Amazon fallback: Priority 50
- Third-party seller: Priority 30
### 4. Create Manual Overrides (Optional)
Navigate to **Markets & Monetization → Buying Options**
Only create when needed:
- Bean available on alternative marketplace (Amazon)
- Special seller arrangement
- Temporarily disable for specific market
**Example:**
- Coffee Bean: "Ethiopian Yirgacheffe"
- Market: "Germany - Amazon"
- URL Override: "https://amazon.de/dp/B08XYZ"
### 5. Manage URL Availability (Optional)
Navigate to **Queue & Crawler Management → Crawl URLs**
Use the `available` field to control per-URL availability:
**When to mark a URL as unavailable:**
- Product is out of stock
- URL is broken/404
- Roaster discontinued the product
- Temporary unavailability
**How to manage:**
1. Filter by roaster config or search for specific URL
2. Toggle the "Available" switch on/off
3. Use the "Available" filter to find all unavailable URLs
**Note:** Unavailable URLs are automatically excluded from API responses, so customers won't see purchase links for them.
## How URL Selection Works
For each visitor, the system checks:
1. **Availability Check** → Filter out URLs where `available = false`
2. **Manual Override?** → Use BuyingOption.urlOverride if exists
3. **Priority Selection** → Find configs serving visitor's country, pick highest priority
4. **Affiliate Transform** → Apply market's affiliate program to URL
5. **Return URL** → Visitor sees affiliate-tracked purchase link
## Testing
After setup, test with different countries:
1. Open API in browser: `/api/coffee-beans?visitorCountryId=DE`
2. Check `url` field has affiliate tracking
3. Verify correct URL returned for each country
## Troubleshooting
**Bean not showing for country:**
- Check RoasterCrawlConfig.market serves that country
- Check CrawlUrl.available = true
- Check Market.isActive = true
- Check AffiliateProgram.isActive = true (if used)
**Wrong URL returned:**
- Check priorities (higher wins)
- Check for manual BuyingOption override
- Verify market countries include visitor's country
Testing Strategy¶
Manual Testing¶
-
MarketCrudController:
- Create market with shipping region selector
- Verify countries auto-populate
- Test adding/removing countries
- Test ManyToMany persistence (by_reference: false)
- Test validation (at least 1 country required)
-
AffiliateProgramCrudController:
- Create program for each provider type
- Test CUSTOM provider requires urlPattern
- Test reserved parameter validation
- Test JSON parameter validation
- Verify markets list shows on detail page
-
BuyingOptionCrudController:
- Create manual override
- Test unique constraint (bean + market)
- Test URL validation
- Verify urlOverride is required
-
RoasterCrawlConfigCrudController:
- Add market to existing config
- Set priority
- Verify form saves correctly
-
CrawlUrlCrudController:
- Toggle available field on/off
- Verify switch UI works correctly
- Test available filter (show only unavailable URLs)
- Verify available field displays on index and detail pages
- Test that unavailable URLs are excluded from API responses
Integration Testing¶
-
Full Workflow:
- Create AffiliateProgram
- Create Market with countries
- Create RoasterCrawlConfig with market
- Verify bean shows in API for those countries
-
Form Validation:
- Test entity-level constraints
- Test unique constraints
- Test required fields
🎯 Success Criteria¶
- All five CRUD controllers created/updated (MarketCrudController, AffiliateProgramCrudController, BuyingOptionCrudController, RoasterCrawlConfigCrudController, CrawlUrlCrudController)
- Navigation menu includes Markets & Monetization section
- Shipping region selector works for market countries (ManyToMany)
- Affiliate program validation prevents invalid templates
- BuyingOption enforces unique constraint
- RoasterCrawlConfig includes market and priority fields
- CrawlUrl includes available field with switch UI and filter
- All CRUD operations work (create, read, update, delete)
- Form validation prevents invalid data
- Help text guides admins through configuration
- Admin guide documentation complete
- All manual tests pass
Related Files¶
Files to Create:¶
src/Controller/Admin/MarketCrudController.phpsrc/Controller/Admin/AffiliateProgramCrudController.phpsrc/Controller/Admin/BuyingOptionCrudController.phpdocs/admin-guide-multi-market.md
Files to Modify:¶
src/Controller/Admin/RoasterCrawlConfigCrudController.phpsrc/Controller/Admin/CrawlUrlCrudController.phpsrc/Controller/Admin/DashboardController.php
Files to Reuse:¶
assets/admin/shipping-configuration.js(for shipping region selector)
Files Referenced:¶
src/Entity/Market.phpsrc/Entity/AffiliateProgram.phpsrc/Entity/BuyingOption.phpsrc/Entity/RoasterCrawlConfig.phpsrc/Entity/CrawlUrl.php
Next Steps¶
After completing this phase:
- Proceed to Phase 7 (Matrix UI) for bean-centric buying options interface (optional)
- Admin can now configure entire multi-market system
- Ready for production use