Compare commits

...

11 Commits

Author SHA1 Message Date
148cd7c5c6 fix: stack company name below logo and add display_name fallback
Place the org name under the provider logo instead of beside it.
Fall back to display_name when ddhh_org_name user meta is empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:23:31 +09:00
7d14914b02 fix: left-align Registrieren button on Anbieter Login page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:51:45 +09:00
c21d7000ef feat: match Anbieter Login form styles to Mentor Login and show company name on Jobangebot
Anbieter Login (/anbieter-login/):
- Add auth-forms.css with styles matching the Mentor Login reference
  (navy pill buttons, bold #333 labels at 18px, consistent input sizing)
- Enqueue CSS only on the login page via stored page ID
- Strip legacy inline styles from page content via the_content filter
- Inject "Passwort vergessen?" link after login form
- Pixel-perfect field alignment between registration and login columns
  (matching Formidable's 97px field spacing, label padding, and margins)
- Override Formidable's flex-row submit wrapper for full-width button

Jobangebot (single job_offer):
- Display company name next to provider logo in a flex .job-header container
- Graceful fallback when logo or org name is missing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:46:42 +09:00
b7c6bb79e7 fix: repair job_type dropdown options and remove job_logo from forms
The job_type select field had empty options because Formidable stores
them in a top-level `options` key, not nested inside `field_options`.
The job_logo field is removed from both submission and edit forms since
the logo is managed per-provider on the dashboard.

Includes a one-time repair migration that fixes existing fields in the
database (updates job_type options, deletes job_logo fields).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 13:37:20 +09:00
346ef5097b fix: read logo from provider user meta instead of job post meta
The logo is stored as `ddhh_provider_logo` on the post author (provider),
not as `job_logo` on the job post. This matches the existing logic in
class-template.php.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 13:09:28 +09:00
f0de02ca94 feat: register custom Elementor dynamic tags for job offer fields
Adds a "Stellenangebot" group to Elementor's dynamic tags dropdown with
tags for Standort, Art, Bewerbungsfrist, Kontakt-E-Mail (text), and
Logo (image). This removes the dependency on Elementor Pro's ACF module
for displaying job offer fields in templates and loop items.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:46:44 +09:00
0bef634eb8 fix: correct Formidable API usage and move activation hooks to top level
- Replace non-existent FrmFormActionsController::create_action() with proper API
  - Use get_form_actions('wppost')->prepare_new() pattern
  - Affects job submission, edit, and deactivation forms
- Move register_activation_hook() to main plugin file top level
  - WordPress requires activation hooks at bootstrap, not in plugins_loaded
  - Fixes missing page creation on plugin activation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 17:29:23 +09:00
f8ec35e72f Update plugin information 2026-02-03 16:05:01 +09:00
290c4e427f docs(quick-002): fix duplicate mentor notifications on job republish
Quick task completed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:34:40 +09:00
1b4449ae12 docs(quick-002): complete duplicate notification fix task
Tasks completed: 1/1
- Add post meta guard to prevent duplicate mentor notifications

SUMMARY: .planning/quick/002-fix-duplicate-mentor-notifications-on-jo/002-SUMMARY.md
2026-01-29 14:29:28 +09:00
4145a92ca7 fix(quick-002): prevent duplicate mentor notifications on job republish
- Add post meta guard `_ddhh_mentors_notified` to track notification status
- Check meta before scheduling notifications, skip if already notified
- Set meta flag after successful batch scheduling
- Prevents re-notification when job edited and republished (pending -> publish)
- Maintains existing publish -> publish guard logic
2026-01-29 14:28:00 +09:00
17 changed files with 937 additions and 244 deletions

View File

@@ -12,7 +12,7 @@ See: .planning/PROJECT.md (updated 2026-01-14)
Phase: 7 of 7 (Testing & Polish) Phase: 7 of 7 (Testing & Polish)
Plan: 3 of 3 in current phase Plan: 3 of 3 in current phase
Status: Phase complete - PROJECT COMPLETE! Status: Phase complete - PROJECT COMPLETE!
Last activity: 2026-01-29 — Completed Plan 07-03 (Admin Flow & Deployment Prep) Last activity: 2026-01-29 — Completed Quick Task 002: Fix duplicate mentor notifications on job republish
Progress: ████████████████████ 100% Progress: ████████████████████ 100%
@@ -113,10 +113,17 @@ All 7 phases finished. All identified UX/notification issues resolved. System is
**Production deployment:** Ready to proceed following `.planning/phases/07-testing-polish/DEPLOYMENT-CHECKLIST.md` **Production deployment:** Ready to proceed following `.planning/phases/07-testing-polish/DEPLOYMENT-CHECKLIST.md`
### Quick Tasks Completed
| # | Description | Date | Commit | Directory |
|---|-------------|------|--------|-----------|
| 001 | UX & Notification Polish | 2026-01-29 | N/A | [001-ux-notification-polish](./quick/001-ux-notification-polish/) |
| 002 | Fix duplicate mentor notifications on job republish | 2026-01-29 | 4145a92 | [002-fix-duplicate-mentor-notifications-on-jo](./quick/002-fix-duplicate-mentor-notifications-on-jo/) |
## Session Continuity ## Session Continuity
Last session: 2026-01-29 Last session: 2026-01-29
Stopped at: Completed Quick Task 001 (UX & Notification Polish) - all 4 deferred issues resolved Stopped at: Completed Quick Task 002: Fix duplicate mentor notifications on job republish
Resume file: None Resume file: None
**Project Status:** ✅ COMPLETE - All 7 phases finished, all polish items complete, system ready for production deployment **Project Status:** ✅ COMPLETE - All 7 phases finished, all polish items complete, system ready for production deployment

View File

@@ -0,0 +1,124 @@
---
phase: quick-002
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- includes/class-notifications.php
autonomous: true
must_haves:
truths:
- "Mentors receive notification when a job is first published"
- "Mentors do NOT receive notification when a previously-published job is edited and republished"
- "Each job can only trigger mentor notifications once in its lifetime"
artifacts:
- path: "includes/class-notifications.php"
provides: "Duplicate notification guard using post meta"
contains: "_ddhh_mentors_notified"
key_links:
- from: "notify_mentors_on_job_publish"
to: "post_meta _ddhh_mentors_notified"
via: "get_post_meta check before scheduling, update_post_meta after scheduling"
pattern: "get_post_meta.*_ddhh_mentors_notified"
---
<objective>
Fix duplicate mentor notifications when a job is edited and republished.
Purpose: Mentors currently receive "new job" notifications every time a job transitions to
publish status -- including after edits (pending -> publish). The notification should only
fire once per job, on the very first publication. Subsequent republications after edits
should be silent for mentors (the admin already gets a separate edit notification).
Output: Updated `notify_mentors_on_job_publish` method with a post meta guard that prevents
duplicate notifications.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/execute-plan.md
@~/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@includes/class-notifications.php
@includes/class-scheduler.php
The bug is in `notify_mentors_on_job_publish()` (line ~430). The current guard:
```php
if ( 'publish' !== $new_status || 'publish' === $old_status ) {
return;
}
```
This correctly prevents re-notification when a published post is updated (publish -> publish),
but it does NOT prevent re-notification when a previously-published job is edited and reset
to pending (per decision 03-02: "Post status reset to pending after edit"), then republished
by admin (pending -> publish). This second publish triggers notifications again.
The job lifecycle that causes the bug:
1. Provider submits -> status: pending
2. Admin publishes -> pending to publish -> mentors notified (CORRECT)
3. Provider edits -> status reset to pending (per 03-02)
4. Admin republishes -> pending to publish -> mentors notified AGAIN (BUG)
</context>
<tasks>
<task type="auto">
<name>Task 1: Add post meta guard to prevent duplicate mentor notifications</name>
<files>includes/class-notifications.php</files>
<action>
In the `notify_mentors_on_job_publish` method, add a post meta check AFTER the existing
status transition guard (line ~437) and BEFORE the mentor query (line ~442):
1. Check if post meta `_ddhh_mentors_notified` exists and equals `'1'` for this post.
If it does, log a message like "DDHH Job Manager: Skipping mentor notification for
job #%d - mentors already notified on initial publish" and return early.
2. After the `DDHH_JM_Scheduler::schedule_mentor_notification_batch()` call succeeds
(line ~457), set post meta: `update_post_meta( $post->ID, '_ddhh_mentors_notified', '1' )`.
Use underscore-prefixed meta key `_ddhh_mentors_notified` so it is hidden from the
WordPress custom fields UI.
Do NOT change any other logic in this method or file. The existing status transition
guard should remain as-is (it still serves a purpose for publish->publish transitions).
</action>
<verify>
1. Read the modified file and confirm:
- `get_post_meta( $post->ID, '_ddhh_mentors_notified', true )` check exists before mentor query
- `update_post_meta( $post->ID, '_ddhh_mentors_notified', '1' )` exists after scheduling
- No other methods were modified
2. Run `php -l includes/class-notifications.php` to confirm no syntax errors
</verify>
<done>
The `notify_mentors_on_job_publish` method checks for `_ddhh_mentors_notified` post meta
before scheduling notifications, and sets that meta after scheduling. This ensures mentors
are only notified once per job, regardless of how many times the job transitions through
pending -> publish.
</done>
</task>
</tasks>
<verification>
1. `php -l includes/class-notifications.php` passes with no syntax errors
2. The `_ddhh_mentors_notified` meta key is checked before notification scheduling
3. The `_ddhh_mentors_notified` meta key is set after successful scheduling
4. No other notification methods were modified
5. The existing `transition_post_status` guard logic is preserved
</verification>
<success_criteria>
- First publish of a job (pending -> publish) triggers mentor notifications and sets meta flag
- Subsequent publishes of the same job (pending -> publish after edit) skip mentor notifications
- Admin notifications for edits/republications are unaffected
- No PHP syntax errors introduced
</success_criteria>
<output>
After completion, create `.planning/quick/002-fix-duplicate-mentor-notifications-on-jo/002-SUMMARY.md`
</output>

View File

@@ -0,0 +1,163 @@
---
phase: quick-002
plan: 01
subsystem: notifications
tags: [mentor-notifications, duplicate-prevention, post-meta, bug-fix]
requires: [05-01, 05-03]
provides:
- Duplicate notification prevention using post meta
- One-time mentor notification guarantee per job
affects: []
tech-stack:
added: []
patterns:
- "Post meta flags for one-time event tracking"
- "Idempotent notification scheduling"
key-files:
created: []
modified:
- includes/class-notifications.php
decisions:
- key: "Use post meta `_ddhh_mentors_notified` for duplicate prevention"
context: "Need to track whether mentors have been notified for a specific job"
options: ["Post meta flag", "Custom database table", "Transient cache"]
chosen: "Post meta flag"
rationale: "Simple, reliable, persists forever, underscore-prefix hides from UI"
- key: "Set meta flag after scheduling, not after emails sent"
context: "When to mark job as notified"
options: ["After scheduling batch", "After all emails sent", "Before scheduling"]
chosen: "After scheduling batch"
rationale: "Scheduling is synchronous and reliable; email sending is async and could fail individually"
metrics:
duration: 2
completed: 2026-01-29
---
# Quick Task 002: Fix Duplicate Mentor Notifications
**One-liner:** Post meta guard prevents mentor re-notification on job republish after edits
## What Was Built
Fixed a bug where mentors received duplicate "new job" notifications when a job was edited and republished.
**The Problem:**
The job lifecycle includes a status reset to `pending` after provider edits (decision 03-02). This means:
1. Provider submits → pending
2. Admin publishes → pending to publish → mentors notified ✓
3. Provider edits → status reset to pending
4. Admin republishes → pending to publish → mentors notified AGAIN ✗
The existing guard `if ( 'publish' === $old_status )` only prevented publish→publish transitions, not pending→publish after edits.
**The Solution:**
Added a post meta flag `_ddhh_mentors_notified` that:
- Is checked before scheduling notifications (early return if already set)
- Is set after successful batch scheduling
- Persists for the lifetime of the job post
- Uses underscore prefix to hide from WordPress custom fields UI
## Technical Implementation
**Modified:** `includes/class-notifications.php`
1. **Pre-scheduling guard** (after line 439):
- `get_post_meta( $post->ID, '_ddhh_mentors_notified', true )`
- If value is `'1'`, log skip message and return early
- Prevents duplicate scheduling entirely
2. **Post-scheduling flag** (after line 469):
- `update_post_meta( $post->ID, '_ddhh_mentors_notified', '1' )`
- Sets flag after `schedule_mentor_notification_batch()` succeeds
- Ensures future republish attempts skip notification
**Why after scheduling, not after sending?**
- Scheduling is synchronous and happens immediately
- Email sending is async (Action Scheduler batches)
- Individual emails might fail, but we still don't want re-notifications
- The intent to notify has been recorded, which is what matters
## Verification
**Syntax Check:**
```bash
php -l includes/class-notifications.php
# No syntax errors detected
```
**Code Review:**
-`get_post_meta` check exists before mentor query
-`update_post_meta` exists after scheduling
- ✅ No other notification methods modified
- ✅ Existing publish→publish guard preserved
- ✅ Underscore-prefixed meta key (hidden from UI)
## Job Lifecycle Flow (After Fix)
1. **Initial submission** (pending):
- No notification (job not published yet)
- `_ddhh_mentors_notified` = not set
2. **First publish** (pending → publish):
- ✅ Mentors notified
- `_ddhh_mentors_notified` = '1'
3. **Provider edits** (publish → pending):
- No notification (deactivation)
- `_ddhh_mentors_notified` = still '1'
4. **Republish after edit** (pending → publish):
- ❌ Mentors NOT notified (meta flag prevents it)
- `_ddhh_mentors_notified` = still '1'
5. **Any future republish**:
- ❌ Mentors NOT notified (meta flag persists)
## Impact
**Fixed:**
- Mentors no longer receive duplicate notifications for the same job
- Reduces notification fatigue and confusion
- Maintains trust in the notification system
**Unchanged:**
- Admin notifications for edits (still sent, as intended)
- First-time publish notifications (still sent)
- Publish→publish guard (still prevents updates to published jobs)
- Mentor opt-in/opt-out functionality
**Edge Cases Handled:**
- Job edited multiple times: Only first publish notifies
- Job unpublished and republished: Only first publish notifies
- Job trashed and restored: Meta persists, no re-notification
## Deviations from Plan
None - plan executed exactly as written.
## Testing Notes
**To verify in production:**
1. Create test job, submit as provider → status: pending
2. Admin publishes → Check Action Scheduler for mentor notification batches
3. Provider edits job → status: pending
4. Admin republishes → Check Action Scheduler - should see NO new batches
5. Check error_log for: "Skipping mentor notification for job #X - mentors already notified on initial publish"
**Manual database check:**
```sql
SELECT post_id, meta_value
FROM wp_postmeta
WHERE meta_key = '_ddhh_mentors_notified';
```
Should show '1' for all published jobs that have triggered notifications.
## Next Phase Readiness
**Blockers:** None
**Dependencies satisfied:** Builds on existing notification system (05-01, 05-03)
**Follow-up needed:** None - fix is complete and self-contained
This was a targeted bug fix with no architectural implications. The notification system is now fully idempotent - each job triggers exactly one mentor notification batch in its lifetime.

165
assets/css/auth-forms.css Normal file
View File

@@ -0,0 +1,165 @@
/**
* Auth Forms Styles — Anbieter Login Page
*
* Matches the Mentor:innen Login styles from general.css.
* Loaded only on the anbieter-login page to override baked-in inline CSS.
*
* @package DDHH_Job_Manager
*/
/* -----------------------------------------------
Layout: two-column container
----------------------------------------------- */
.ddhh-auth-container {
display: flex;
gap: 2rem;
margin: 2rem 0;
}
@media (max-width: 768px) {
.ddhh-auth-container {
flex-direction: column;
}
}
/* -----------------------------------------------
Sections: remove gray card backgrounds
----------------------------------------------- */
.ddhh-register-section,
.ddhh-login-section {
flex: 1;
padding: 2rem;
background: transparent;
border-radius: 0;
border: none;
}
.ddhh-register-section h2,
.ddhh-login-section h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
font-size: 1.5rem;
}
/* -----------------------------------------------
Labels: match Mentor Login (color #333, 18px, bold)
High specificity to override Formidable's
.with_frm_style .frm_primary_label selectors
----------------------------------------------- */
.ddhh-auth-container label,
.ddhh-auth-container .frm_forms.with_frm_style .frm_primary_label {
color: #333;
font-size: 18px;
font-weight: 700;
line-height: 18px;
margin-bottom: 0;
}
/* -----------------------------------------------
Inputs: match Mentor Login field sizing
----------------------------------------------- */
.ddhh-auth-container input[type="text"],
.ddhh-auth-container input[type="email"],
.ddhh-auth-container input[type="password"],
.ddhh-auth-container input[type="url"],
.ddhh-auth-container input[type="tel"],
.ddhh-auth-container select {
width: 100%;
min-height: 40px;
max-width: 350px;
border: 0.0625rem solid;
border-radius: 3px;
padding: 0.1875rem 0.3125rem;
margin: 0 6px 16px 0;
box-sizing: border-box;
font-size: 16px;
line-height: normal;
font-family: "Poppins", Sans, Helvetica, Arial;
}
/* -----------------------------------------------
Formidable submit wrapper: override flex-row
so the button stretches to full width
----------------------------------------------- */
.ddhh-auth-container .frm_submit.frm_flex {
flex-direction: column;
align-items: flex-start;
}
/* -----------------------------------------------
Buttons: navy pill with red hover
High specificity to override Formidable's
.frm_style_formidable-style.with_frm_style selectors
----------------------------------------------- */
.ddhh-auth-container input[type="submit"],
.ddhh-auth-container button[type="submit"],
.ddhh-auth-container .frm_forms.with_frm_style .frm_submit button.frm_button_submit {
background-color: var(--wp--preset--color--primary, #003063);
border-width: 0;
color: #fff;
font-family: inherit;
font-size: inherit;
line-height: inherit;
padding: 0.4em 1.333em;
border-radius: 100px;
text-decoration: none;
width: 100%;
max-width: 350px;
cursor: pointer;
transition: all 0.7s ease;
box-sizing: border-box;
}
.ddhh-auth-container input[type="submit"]:hover,
.ddhh-auth-container button[type="submit"]:hover,
.ddhh-auth-container .frm_forms.with_frm_style .frm_submit button.frm_button_submit:hover {
background-color: var(--wp--preset--color--hhred, #E40613);
}
/* -----------------------------------------------
wp_login_form() specific: style the <p> wrappers
----------------------------------------------- */
.ddhh-login-section .login-username,
.ddhh-login-section .login-password {
margin-bottom: 20px;
}
.ddhh-login-section .login-remember,
.ddhh-login-section .login-submit {
margin-bottom: 1rem;
}
.ddhh-login-section .login-username br,
.ddhh-login-section .login-password br {
display: none;
}
.ddhh-login-section .login-username label,
.ddhh-login-section .login-password label {
display: block;
font-weight: 700;
margin-bottom: 0;
padding-bottom: 3px;
}
.ddhh-login-section .login-remember label {
font-weight: 700;
}
/* -----------------------------------------------
Passwort vergessen link
----------------------------------------------- */
.ddhh-login-section .login-lost-password {
margin-top: 0.75rem;
}
.ddhh-login-section .login-lost-password a {
color: var(--wp--preset--color--primary, #003063);
text-decoration: underline;
font-size: 0.9em;
}
.ddhh-login-section .login-lost-password a:hover {
color: var(--wp--preset--color--hhred, #E40613);
}

View File

@@ -1,11 +1,11 @@
<?php <?php
/** /**
* Plugin Name: Digital Dabei Job Manager * Plugin Name: Digital Dabei Job Manager
* Plugin URI: https://www.hamburg.de/digital-dabei * Plugin URI: https://hamburg-digital-dabei.de
* Description: Closed job board for provider self-registration and mentor applications * Description: Closed job board for provider self-registration and mentor applications
* Version: 1.0.0 * Version: 1.0.0
* Author: digital dabei Hamburg * Author: vihais
* Author URI: https://www.hamburg.de/digital-dabei * Author URI: https://hamburg-digital-dabei.de
* Text Domain: ddhh-job-manager * Text Domain: ddhh-job-manager
* Domain Path: /languages * Domain Path: /languages
* Requires at least: 6.0 * Requires at least: 6.0
@@ -45,8 +45,13 @@ require_once DDHH_JM_PLUGIN_DIR . 'includes/class-template.php';
require_once DDHH_JM_PLUGIN_DIR . 'includes/class-admin-ui.php'; require_once DDHH_JM_PLUGIN_DIR . 'includes/class-admin-ui.php';
require_once DDHH_JM_PLUGIN_DIR . 'includes/class-user-preferences.php'; require_once DDHH_JM_PLUGIN_DIR . 'includes/class-user-preferences.php';
require_once DDHH_JM_PLUGIN_DIR . 'includes/class-scheduler.php'; require_once DDHH_JM_PLUGIN_DIR . 'includes/class-scheduler.php';
require_once DDHH_JM_PLUGIN_DIR . 'includes/class-elementor-tags.php';
require_once DDHH_JM_PLUGIN_DIR . 'includes/class-ddhh-job-manager.php'; require_once DDHH_JM_PLUGIN_DIR . 'includes/class-ddhh-job-manager.php';
// Register activation and deactivation hooks (must be at top level).
register_activation_hook( __FILE__, array( 'DDHH_JM_Activator', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'DDHH_JM_Deactivator', 'deactivate' ) );
/** /**
* Initialize the plugin. * Initialize the plugin.
*/ */
@@ -54,3 +59,11 @@ function ddhh_jm_init() {
DDHH_JM_Job_Manager::get_instance(); DDHH_JM_Job_Manager::get_instance();
} }
add_action( 'plugins_loaded', 'ddhh_jm_init', 10 ); add_action( 'plugins_loaded', 'ddhh_jm_init', 10 );
/**
* Initialize Elementor dynamic tags when Elementor is loaded.
*/
function ddhh_jm_elementor_init() {
DDHH_JM_Elementor_Tags::init();
}
add_action( 'elementor/init', 'ddhh_jm_elementor_init' );

View File

@@ -68,17 +68,6 @@ class DDHH_JM_ACF_Fields {
'type' => 'email', 'type' => 'email',
'required' => 1, 'required' => 1,
), ),
// Job Logo
array(
'key' => 'field_job_logo',
'label' => __( 'Logo', 'ddhh-job-manager' ),
'name' => 'job_logo',
'type' => 'image',
'required' => 0,
'return_format' => 'id',
'preview_size' => 'thumbnail',
'library' => 'all',
),
// Job Deactivation Reason (internal, admin-only) // Job Deactivation Reason (internal, admin-only)
array( array(
'key' => 'field_job_deactivation_reason', 'key' => 'field_job_deactivation_reason',

View File

@@ -43,10 +43,6 @@ class DDHH_JM_Job_Manager {
* Initialize hooks * Initialize hooks
*/ */
private function init_hooks() { private function init_hooks() {
// Register activation and deactivation hooks
register_activation_hook( DDHH_JM_PLUGIN_FILE, array( 'DDHH_JM_Activator', 'activate' ) );
register_deactivation_hook( DDHH_JM_PLUGIN_FILE, array( 'DDHH_JM_Deactivator', 'deactivate' ) );
// Initialize post types // Initialize post types
add_action( 'init', array( 'DDHH_JM_Post_Types', 'register' ) ); add_action( 'init', array( 'DDHH_JM_Post_Types', 'register' ) );

View File

@@ -0,0 +1,58 @@
<?php
/**
* Elementor Dynamic Tags integration.
*
* Registers custom dynamic tags for job offer fields so they appear
* in the Elementor editor's dynamic tags dropdown under a
* "Stellenangebot" group.
*
* @package DDHH_Job_Manager
*/
defined( 'ABSPATH' ) || exit;
/**
* Class DDHH_JM_Elementor_Tags
*/
class DDHH_JM_Elementor_Tags {
/**
* Group slug used for all job offer tags.
*/
const GROUP_SLUG = 'ddhh-job-offer';
/**
* Initialize hooks.
*/
public static function init() {
add_action( 'elementor/dynamic_tags/register', array( __CLASS__, 'register_tags' ) );
}
/**
* Register the dynamic tag group and individual tags.
*
* @param \Elementor\Core\DynamicTags\Manager $manager Elementor dynamic tags manager.
*/
public static function register_tags( $manager ) {
$manager->register_group(
self::GROUP_SLUG,
array(
'title' => 'Stellenangebot',
)
);
$tag_dir = DDHH_JM_PLUGIN_DIR . 'includes/elementor-tags/';
require_once $tag_dir . 'class-tag-job-location.php';
require_once $tag_dir . 'class-tag-job-type.php';
require_once $tag_dir . 'class-tag-job-deadline.php';
require_once $tag_dir . 'class-tag-job-contact-email.php';
require_once $tag_dir . 'class-tag-job-logo.php';
$manager->register( new DDHH_JM_Tag_Job_Location() );
$manager->register( new DDHH_JM_Tag_Job_Type() );
$manager->register( new DDHH_JM_Tag_Job_Deadline() );
$manager->register( new DDHH_JM_Tag_Job_Contact_Email() );
$manager->register( new DDHH_JM_Tag_Job_Logo() );
}
}

View File

@@ -156,6 +156,7 @@ class DDHH_JM_Formidable {
add_action( 'init', array( __CLASS__, 'create_registration_form' ), 11 ); add_action( 'init', array( __CLASS__, 'create_registration_form' ), 11 );
add_action( 'init', array( __CLASS__, 'create_job_submission_form' ), 11 ); add_action( 'init', array( __CLASS__, 'create_job_submission_form' ), 11 );
add_action( 'init', array( __CLASS__, 'create_job_edit_form' ), 11 ); add_action( 'init', array( __CLASS__, 'create_job_edit_form' ), 11 );
add_action( 'init', array( __CLASS__, 'repair_job_form_fields' ), 12 );
add_action( 'init', array( __CLASS__, 'create_job_deactivation_form' ), 11 ); add_action( 'init', array( __CLASS__, 'create_job_deactivation_form' ), 11 );
add_action( 'init', array( __CLASS__, 'create_job_application_form' ), 11 ); add_action( 'init', array( __CLASS__, 'create_job_application_form' ), 11 );
@@ -814,13 +815,7 @@ class DDHH_JM_Formidable {
'required' => '1', 'required' => '1',
'form_id' => $form_id, 'form_id' => $form_id,
'field_order' => 4, 'field_order' => 4,
'field_options' => array( 'options' => array( '', 'Vollzeit', 'Teilzeit', 'Ehrenamt' ),
'options' => array(
'Vollzeit' => 'Vollzeit',
'Teilzeit' => 'Teilzeit',
'Ehrenamt' => 'Ehrenamt',
),
),
), ),
array( array(
'name' => 'Bewerbungsfrist', 'name' => 'Bewerbungsfrist',
@@ -841,19 +836,6 @@ class DDHH_JM_Formidable {
'form_id' => $form_id, 'form_id' => $form_id,
'field_order' => 6, 'field_order' => 6,
), ),
array(
'name' => 'Logo',
'field_key' => 'job_logo',
'type' => 'file',
'required' => '0',
'form_id' => $form_id,
'field_order' => 7,
'field_options' => array(
'restrict' => '1',
'allowed_types' => 'image/jpeg,image/png',
'max_size' => '2',
),
),
); );
// Store field IDs for form action mapping // Store field IDs for form action mapping
@@ -867,49 +849,20 @@ class DDHH_JM_Formidable {
// Create the Create Post action // Create the Create Post action
if ( ! empty( $field_ids ) ) { if ( ! empty( $field_ids ) ) {
$action_values = array( $action_control = FrmFormActionsController::get_form_actions( 'wppost' );
'menu_order' => 1, $new_action = $action_control->prepare_new( $form_id );
'post_status' => 'published', $new_action->post_content['post_type'] = 'job_offer';
'post_content' => array( $new_action->post_content['post_status'] = 'pending';
'post_type' => 'job_offer', $new_action->post_content['post_title'] = $field_ids['job_title'];
'post_status' => 'pending', $new_action->post_content['post_content'] = $field_ids['job_description'];
'post_title' => $field_ids['job_title'], $new_action->post_content['post_author'] = 'current_user';
'post_content' => $field_ids['job_description'], $new_action->post_content['post_custom_fields'] = array(
'post_author' => 'current_user', array( 'meta_name' => 'job_location', 'field_id' => $field_ids['job_location'] ),
'post_custom_fields' => array( array( 'meta_name' => 'job_type', 'field_id' => $field_ids['job_type'] ),
array( array( 'meta_name' => 'job_deadline', 'field_id' => $field_ids['job_deadline'] ),
'meta_name' => 'job_location', array( 'meta_name' => 'job_contact_email', 'field_id' => $field_ids['job_contact_email'] ),
'field_id' => $field_ids['job_location'],
),
array(
'meta_name' => 'job_type',
'field_id' => $field_ids['job_type'],
),
array(
'meta_name' => 'job_deadline',
'field_id' => $field_ids['job_deadline'],
),
array(
'meta_name' => 'job_contact_email',
'field_id' => $field_ids['job_contact_email'],
),
array(
'meta_name' => 'job_logo',
'field_id' => $field_ids['job_logo'],
),
),
),
);
// Create the form action using the proper API
FrmFormActionsController::create_action(
$form_id,
array(
'post_excerpt' => 'wppost',
'post_content' => $action_values,
'menu_order' => 1,
)
); );
$action_control->save_settings( $new_action );
} }
} }
@@ -984,13 +937,7 @@ class DDHH_JM_Formidable {
'required' => '1', 'required' => '1',
'form_id' => $form_id, 'form_id' => $form_id,
'field_order' => 4, 'field_order' => 4,
'field_options' => array( 'options' => array( '', 'Vollzeit', 'Teilzeit', 'Ehrenamt' ),
'options' => array(
'Vollzeit' => 'Vollzeit',
'Teilzeit' => 'Teilzeit',
'Ehrenamt' => 'Ehrenamt',
),
),
), ),
array( array(
'name' => 'Bewerbungsfrist', 'name' => 'Bewerbungsfrist',
@@ -1011,19 +958,6 @@ class DDHH_JM_Formidable {
'form_id' => $form_id, 'form_id' => $form_id,
'field_order' => 6, 'field_order' => 6,
), ),
array(
'name' => 'Logo',
'field_key' => 'job_logo',
'type' => 'file',
'required' => '0',
'form_id' => $form_id,
'field_order' => 7,
'field_options' => array(
'restrict' => '1',
'allowed_types' => 'image/jpeg,image/png',
'max_size' => '2',
),
),
); );
// Store field IDs for form action mapping // Store field IDs for form action mapping
@@ -1037,52 +971,62 @@ class DDHH_JM_Formidable {
// Create the Update Post action // Create the Update Post action
if ( ! empty( $field_ids ) ) { if ( ! empty( $field_ids ) ) {
$action_values = array( $action_control = FrmFormActionsController::get_form_actions( 'wppost' );
'menu_order' => 1, $new_action = $action_control->prepare_new( $form_id );
'post_status' => 'published', $new_action->post_content['post_type'] = 'job_offer';
'post_content' => array( $new_action->post_content['post_status'] = 'pending';
'post_type' => 'job_offer', $new_action->post_content['post_title'] = $field_ids['job_title'];
'post_status' => 'pending', $new_action->post_content['post_content'] = $field_ids['job_description'];
'post_title' => $field_ids['job_title'], $new_action->post_content['post_id'] = 'id_param';
'post_content' => $field_ids['job_description'], $new_action->post_content['post_custom_fields'] = array(
'post_id' => 'id_param', array( 'meta_name' => 'job_location', 'field_id' => $field_ids['job_location'] ),
'post_custom_fields' => array( array( 'meta_name' => 'job_type', 'field_id' => $field_ids['job_type'] ),
array( array( 'meta_name' => 'job_deadline', 'field_id' => $field_ids['job_deadline'] ),
'meta_name' => 'job_location', array( 'meta_name' => 'job_contact_email', 'field_id' => $field_ids['job_contact_email'] ),
'field_id' => $field_ids['job_location'],
),
array(
'meta_name' => 'job_type',
'field_id' => $field_ids['job_type'],
),
array(
'meta_name' => 'job_deadline',
'field_id' => $field_ids['job_deadline'],
),
array(
'meta_name' => 'job_contact_email',
'field_id' => $field_ids['job_contact_email'],
),
array(
'meta_name' => 'job_logo',
'field_id' => $field_ids['job_logo'],
),
),
),
);
// Create the form action using the proper API
FrmFormActionsController::create_action(
$form_id,
array(
'post_excerpt' => 'wppost',
'post_content' => $action_values,
'menu_order' => 1,
)
); );
$action_control->save_settings( $new_action );
} }
} }
/**
* Repair existing job form fields in the database.
*
* Fixes the job_type select options and removes the job_logo field
* from both the submission and edit forms. Runs once and stores a
* version flag in wp_options to avoid re-running.
*/
public static function repair_job_form_fields() {
if ( ! class_exists( 'FrmField' ) ) {
return;
}
$repair_version = '1';
if ( get_option( 'ddhh_jm_form_repair_version' ) === $repair_version ) {
return;
}
// Fix job_type options on both forms.
$correct_options = array( '', 'Vollzeit', 'Teilzeit', 'Ehrenamt' );
foreach ( array( 'job_type', 'job_type2' ) as $key ) {
$field = FrmField::getOne( $key );
if ( $field ) {
FrmField::update( $field->id, array(
'options' => serialize( $correct_options ),
) );
}
}
// Remove job_logo fields from both forms.
foreach ( array( 'job_logo', 'job_logo2' ) as $key ) {
$field = FrmField::getOne( $key );
if ( $field ) {
FrmField::destroy( $field->id );
}
}
update_option( 'ddhh_jm_form_repair_version', $repair_version );
}
/** /**
* Pre-populate edit form fields with existing post data * Pre-populate edit form fields with existing post data
* *
@@ -1251,31 +1195,15 @@ class DDHH_JM_Formidable {
// Create the Update Post action // Create the Update Post action
if ( ! empty( $field_ids ) ) { if ( ! empty( $field_ids ) ) {
$action_values = array( $action_control = FrmFormActionsController::get_form_actions( 'wppost' );
'menu_order' => 1, $new_action = $action_control->prepare_new( $form_id );
'post_status' => 'published', $new_action->post_content['post_type'] = 'job_offer';
'post_content' => array( $new_action->post_content['post_status'] = 'draft';
'post_type' => 'job_offer', $new_action->post_content['post_id'] = 'id_param';
'post_status' => 'draft', $new_action->post_content['post_custom_fields'] = array(
'post_id' => 'id_param', array( 'meta_name' => 'job_deactivation_reason', 'field_id' => $field_ids['deactivation_reason'] ),
'post_custom_fields' => array(
array(
'meta_name' => 'job_deactivation_reason',
'field_id' => $field_ids['deactivation_reason'],
),
),
),
);
// Create the form action using the proper API
FrmFormActionsController::create_action(
$form_id,
array(
'post_excerpt' => 'wppost',
'post_content' => $action_values,
'menu_order' => 1,
)
); );
$action_control->save_settings( $new_action );
} }
} }

View File

@@ -438,6 +438,18 @@ class DDHH_JM_Notifications {
return; return;
} }
// Check if mentors have already been notified for this job
$already_notified = get_post_meta( $post->ID, '_ddhh_mentors_notified', true );
if ( '1' === $already_notified ) {
error_log(
sprintf(
'DDHH Job Manager: Skipping mentor notification for job #%d - mentors already notified on initial publish',
$post->ID
)
);
return;
}
// Get opted-in mentors // Get opted-in mentors
$mentor_ids = DDHH_JM_User_Preferences::get_opted_in_mentors(); $mentor_ids = DDHH_JM_User_Preferences::get_opted_in_mentors();
@@ -456,6 +468,9 @@ class DDHH_JM_Notifications {
// Schedule async batch notifications // Schedule async batch notifications
$batch_count = DDHH_JM_Scheduler::schedule_mentor_notification_batch( $mentor_ids, $post->ID ); $batch_count = DDHH_JM_Scheduler::schedule_mentor_notification_batch( $mentor_ids, $post->ID );
// Mark job as having notified mentors to prevent duplicate notifications
update_post_meta( $post->ID, '_ddhh_mentors_notified', '1' );
// Log success // Log success
error_log( error_log(
sprintf( sprintf(

View File

@@ -21,6 +21,58 @@ class DDHH_JM_Pages {
public static function setup_hooks() { public static function setup_hooks() {
// Redirect logged-in providers from login page to dashboard // Redirect logged-in providers from login page to dashboard
add_action( 'template_redirect', array( __CLASS__, 'maybe_redirect_logged_in_from_login' ) ); add_action( 'template_redirect', array( __CLASS__, 'maybe_redirect_logged_in_from_login' ) );
// Enqueue auth form styles on anbieter-login page
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_auth_styles' ) );
// Clean up legacy inline styles and inject missing elements on login page
add_filter( 'the_content', array( __CLASS__, 'filter_login_page_content' ) );
}
/**
* Enqueue auth form styles on the anbieter-login page
*/
public static function enqueue_auth_styles() {
$login_page_id = get_option( 'ddhh_jm_login_page_id' );
if ( ! $login_page_id || ! is_page( $login_page_id ) ) {
return;
}
wp_enqueue_style(
'ddhh-jm-auth-forms',
DDHH_JM_PLUGIN_URL . 'assets/css/auth-forms.css',
array(),
DDHH_JM_VERSION
);
}
/**
* Filter login page content to remove legacy inline styles and inject missing elements
*
* @param string $content Page content.
* @return string Filtered content.
*/
public static function filter_login_page_content( $content ) {
$login_page_id = get_option( 'ddhh_jm_login_page_id' );
if ( ! $login_page_id || ! is_page( $login_page_id ) ) {
return $content;
}
// Strip legacy inline <style> blocks baked into the page content
$content = preg_replace( '/<style[^>]*>.*?<\/style>/s', '', $content );
// Inject "Passwort vergessen?" link after the login form if not already present
if ( strpos( $content, 'login-lost-password' ) === false ) {
$lost_pw_html = '<p class="login-lost-password"><a href="' . esc_url( wp_lostpassword_url() ) . '">Passwort vergessen?</a></p>';
// Insert after the closing </form> inside the login section
$pos = strrpos( $content, '</form>' );
if ( false !== $pos ) {
$content = substr_replace( $content, '</form>' . $lost_pw_html, $pos, strlen( '</form>' ) );
}
}
return $content;
} }
/** /**
@@ -109,66 +161,8 @@ class DDHH_JM_Pages {
// Get registration form ID // Get registration form ID
$registration_form_id = DDHH_JM_Formidable::get_registration_form_id(); $registration_form_id = DDHH_JM_Formidable::get_registration_form_id();
// Build page content with inline CSS and two sections // Build page content — styles are loaded via enqueued auth-forms.css
$content = '<style> $content = '';
.ddhh-auth-container {
display: flex;
gap: 2rem;
margin: 2rem 0;
}
.ddhh-register-section,
.ddhh-login-section {
flex: 1;
padding: 2rem;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.ddhh-register-section h2,
.ddhh-login-section h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
font-size: 1.5rem;
}
/* Mobile responsive - stacked layout */
@media (max-width: 768px) {
.ddhh-auth-container {
flex-direction: column;
}
}
/* Form styling for consistency */
.ddhh-auth-container input[type="text"],
.ddhh-auth-container input[type="email"],
.ddhh-auth-container input[type="password"] {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.ddhh-auth-container input[type="submit"],
.ddhh-auth-container button[type="submit"] {
background: #0073aa;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.ddhh-auth-container input[type="submit"]:hover,
.ddhh-auth-container button[type="submit"]:hover {
background: #005a87;
}
</style>';
$content .= '<div class="ddhh-auth-container">'; $content .= '<div class="ddhh-auth-container">';
@@ -197,6 +191,7 @@ class DDHH_JM_Pages {
); );
$content .= wp_login_form( $login_args ); $content .= wp_login_form( $login_args );
$content .= '<p class="login-lost-password"><a href="' . esc_url( wp_lostpassword_url() ) . '">Passwort vergessen?</a></p>';
$content .= '</div>'; $content .= '</div>';
$content .= '</div>'; // .ddhh-auth-container $content .= '</div>'; // .ddhh-auth-container

View File

@@ -47,6 +47,9 @@ class DDHH_JM_Template {
$author = get_userdata( $post->post_author ); $author = get_userdata( $post->post_author );
$author_name = $author ? $author->display_name : ''; $author_name = $author ? $author->display_name : '';
$author_org = get_user_meta( $post->post_author, 'ddhh_org_name', true ); $author_org = get_user_meta( $post->post_author, 'ddhh_org_name', true );
if ( ! $author_org ) {
$author_org = $author_name;
}
// Build job details HTML // Build job details HTML
ob_start(); ob_start();
@@ -55,11 +58,18 @@ class DDHH_JM_Template {
<a href="<?php echo esc_url( get_post_type_archive_link( 'job_offer' ) ); ?>">← Alle Jobangebote</a> <a href="<?php echo esc_url( get_post_type_archive_link( 'job_offer' ) ); ?>">← Alle Jobangebote</a>
</div> </div>
<div class="ddhh-job-offer-details"> <div class="ddhh-job-offer-details">
<?php if ( $job_logo || $author_org ) : ?>
<div class="job-header">
<?php if ( $job_logo ) : ?> <?php if ( $job_logo ) : ?>
<div class="job-logo"> <div class="job-logo">
<?php echo $job_logo; ?> <?php echo $job_logo; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ( $author_org ) : ?>
<span class="job-header-org"><?php echo esc_html( $author_org ); ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="job-meta"> <div class="job-meta">
<?php if ( $author_org ) : ?> <?php if ( $author_org ) : ?>
@@ -137,13 +147,20 @@ class DDHH_JM_Template {
.ddhh-job-offer-details { .ddhh-job-offer-details {
margin: 2em 0; margin: 2em 0;
} }
.ddhh-job-offer-details .job-logo { .ddhh-job-offer-details .job-header {
margin-bottom: 2em; margin-bottom: 2em;
} }
.ddhh-job-offer-details .job-logo img { .ddhh-job-offer-details .job-logo img {
max-width: 200px; max-width: 200px;
height: auto; height: auto;
} }
.ddhh-job-offer-details .job-header-org {
display: block;
margin-top: 0.5em;
font-size: 1.4em;
font-weight: 600;
color: #333;
}
.ddhh-job-offer-details .job-meta { .ddhh-job-offer-details .job-meta {
background: #f5f5f5; background: #f5f5f5;
padding: 1.5em; padding: 1.5em;

View File

@@ -0,0 +1,38 @@
<?php
/**
* Elementor dynamic tag: Kontakt-E-Mail (job_contact_email).
*
* Registered in both TEXT and URL categories so it can be used
* as link href (mailto:) or plain text display.
*
* @package DDHH_Job_Manager
*/
defined( 'ABSPATH' ) || exit;
class DDHH_JM_Tag_Job_Contact_Email extends \Elementor\Core\DynamicTags\Tag {
public function get_name(): string {
return 'ddhh-job-contact-email';
}
public function get_title(): string {
return 'Kontakt-E-Mail';
}
public function get_group(): array {
return array( DDHH_JM_Elementor_Tags::GROUP_SLUG );
}
public function get_categories(): array {
return array(
\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY,
\Elementor\Modules\DynamicTags\Module::URL_CATEGORY,
);
}
public function render(): void {
$value = get_post_meta( get_the_ID(), 'job_contact_email', true );
echo esc_html( $value );
}
}

View File

@@ -0,0 +1,44 @@
<?php
/**
* Elementor dynamic tag: Bewerbungsfrist (job_deadline).
*
* Formats the stored Y-m-d date as DD.MM.YYYY for display.
*
* @package DDHH_Job_Manager
*/
defined( 'ABSPATH' ) || exit;
class DDHH_JM_Tag_Job_Deadline extends \Elementor\Core\DynamicTags\Tag {
public function get_name(): string {
return 'ddhh-job-deadline';
}
public function get_title(): string {
return 'Bewerbungsfrist';
}
public function get_group(): array {
return array( DDHH_JM_Elementor_Tags::GROUP_SLUG );
}
public function get_categories(): array {
return array( \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY );
}
public function render(): void {
$raw = get_post_meta( get_the_ID(), 'job_deadline', true );
if ( empty( $raw ) ) {
return;
}
$timestamp = strtotime( $raw );
if ( false === $timestamp ) {
echo esc_html( $raw );
return;
}
echo esc_html( date_i18n( 'd.m.Y', $timestamp ) );
}
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* Elementor dynamic tag: Standort (job_location).
*
* @package DDHH_Job_Manager
*/
defined( 'ABSPATH' ) || exit;
class DDHH_JM_Tag_Job_Location extends \Elementor\Core\DynamicTags\Tag {
public function get_name(): string {
return 'ddhh-job-location';
}
public function get_title(): string {
return 'Standort';
}
public function get_group(): array {
return array( DDHH_JM_Elementor_Tags::GROUP_SLUG );
}
public function get_categories(): array {
return array( \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY );
}
public function render(): void {
$value = get_post_meta( get_the_ID(), 'job_location', true );
echo wp_kses_post( $value );
}
}

View File

@@ -0,0 +1,68 @@
<?php
/**
* Elementor dynamic tag: Logo.
*
* Extends Data_Tag to return image data (id + url) for use in
* Image widgets and other image-accepting controls.
*
* The logo is stored on the provider (post author) as user meta
* `ddhh_provider_logo`, not on the job post itself.
*
* @package DDHH_Job_Manager
*/
defined( 'ABSPATH' ) || exit;
class DDHH_JM_Tag_Job_Logo extends \Elementor\Core\DynamicTags\Data_Tag {
public function get_name(): string {
return 'ddhh-job-logo';
}
public function get_title(): string {
return 'Logo';
}
public function get_group(): array {
return array( DDHH_JM_Elementor_Tags::GROUP_SLUG );
}
public function get_categories(): array {
return array( \Elementor\Modules\DynamicTags\Module::IMAGE_CATEGORY );
}
protected function register_controls(): void {
$this->add_control(
'fallback',
array(
'label' => 'Fallback',
'type' => \Elementor\Controls_Manager::MEDIA,
)
);
}
public function get_value( array $options = array() ): array {
$post = get_post( get_the_ID() );
$image_id = $post ? get_user_meta( $post->post_author, 'ddhh_provider_logo', true ) : '';
if ( $image_id ) {
$url = wp_get_attachment_image_url( $image_id, 'full' );
if ( $url ) {
return array(
'id' => (int) $image_id,
'url' => $url,
);
}
}
$fallback = $this->get_settings( 'fallback' );
if ( ! empty( $fallback['id'] ) ) {
return $fallback;
}
return array(
'id' => 0,
'url' => '',
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* Elementor dynamic tag: Art (job_type).
*
* Maps raw select values to German display labels.
*
* @package DDHH_Job_Manager
*/
defined( 'ABSPATH' ) || exit;
class DDHH_JM_Tag_Job_Type extends \Elementor\Core\DynamicTags\Tag {
private const LABELS = array(
'vollzeit' => 'Vollzeit',
'teilzeit' => 'Teilzeit',
'ehrenamt' => 'Ehrenamt',
);
public function get_name(): string {
return 'ddhh-job-type';
}
public function get_title(): string {
return 'Art';
}
public function get_group(): array {
return array( DDHH_JM_Elementor_Tags::GROUP_SLUG );
}
public function get_categories(): array {
return array( \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY );
}
public function render(): void {
$raw = get_post_meta( get_the_ID(), 'job_type', true );
$label = self::LABELS[ $raw ] ?? $raw;
echo esc_html( $label );
}
}