Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 519e9df3d7 | |||
| 7acd35821c | |||
| 3e6d703281 | |||
| 267d2b69d7 | |||
| a45a9a901b | |||
| c74a474ddf | |||
| e9c2de3b83 | |||
| 791da5d1cc | |||
| 8307ce67fd | |||
| 5fb4caf701 | |||
| 7590732620 | |||
| 42cfd98cd4 | |||
| 51804f208d | |||
| 080b7c1131 | |||
| 29e8434675 | |||
| 70d515f8ad | |||
| bae32631d2 | |||
| 9f6407df47 | |||
| 707cc0779e | |||
| 3c9e94ed3d | |||
| 3cc39e38b5 | |||
| 064e0ac9e5 | |||
| 881bf478c6 | |||
| d5f7acb323 | |||
| c2388f6d1f | |||
| 17990f9128 | |||
| 0d1dab5e11 | |||
| c9bc45b3f6 | |||
| 733bb6f12f | |||
| 9ace69f010 | |||
| ac79320297 | |||
| 5e09aa6b98 | |||
| a79304e7fb | |||
| 211f3ff9a0 | |||
| 400329a406 | |||
| 0148ee16e1 | |||
| 72d948e4d6 | |||
| f09368cc2c | |||
| 25dd14066c | |||
| 51aafe7af9 | |||
| da8eaa6936 | |||
| e73bb80c6b |
@@ -114,7 +114,6 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
out/
|
out/
|
||||||
site/
|
site/
|
||||||
!src/**/site/
|
|
||||||
*.map
|
*.map
|
||||||
*.css.map
|
*.css.map
|
||||||
*.js.map
|
*.js.map
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
joomla
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
|
|
||||||
<identity>
|
|
||||||
<name>MokoJoomStoreLocator</name>
|
|
||||||
<org>MokoConsulting</org>
|
|
||||||
<description>Joomla store locator package with component and coordinating modules</description>
|
|
||||||
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
|
|
||||||
</identity>
|
|
||||||
<governance>
|
|
||||||
<platform>joomla</platform>
|
|
||||||
<standards-version>05.00.00</standards-version>
|
|
||||||
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/moko-platform</standards-source>
|
|
||||||
</governance>
|
|
||||||
<build>
|
|
||||||
<language>PHP</language>
|
|
||||||
<package-type>joomla-package</package-type>
|
|
||||||
<entry-point>src/</entry-point>
|
|
||||||
</build>
|
|
||||||
</moko-platform>
|
|
||||||
+283
-1001
File diff suppressed because it is too large
Load Diff
@@ -1,213 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Maintenance
|
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
|
||||||
# PATH: /templates/workflows/cascade-dev.yml.template
|
|
||||||
# VERSION: 02.00.00
|
|
||||||
# BRIEF: Forward-merge main → all open branches after every push to main
|
|
||||||
#
|
|
||||||
# +========================================================================+
|
|
||||||
# | CASCADE MAIN → ALL BRANCHES |
|
|
||||||
# +========================================================================+
|
|
||||||
# | |
|
|
||||||
# | Triggers on every push to main (PR merges, bot commits, etc.) |
|
|
||||||
# | |
|
|
||||||
# | 1. List all branches matching: dev, rc/*, beta/*, alpha/* |
|
|
||||||
# | 2. For each: create PR (main → branch), auto-merge if clean |
|
|
||||||
# | 3. On conflict: leave PR open for manual resolution |
|
|
||||||
# | |
|
|
||||||
# +========================================================================+
|
|
||||||
|
|
||||||
name: "Universal: Cascade Main → Dev"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cascade:
|
|
||||||
name: Cascade main → branches
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: >-
|
|
||||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
|
||||||
!contains(github.event.head_commit.message, '[skip cascade]')
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Discover target branches
|
|
||||||
id: branches
|
|
||||||
env:
|
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
|
|
||||||
# Fetch all branches (paginated)
|
|
||||||
PAGE=1
|
|
||||||
ALL_BRANCHES=""
|
|
||||||
while true; do
|
|
||||||
BATCH=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API}/branches?page=${PAGE}&limit=50" \
|
|
||||||
| jq -r '.[].name // empty')
|
|
||||||
[ -z "$BATCH" ] && break
|
|
||||||
ALL_BRANCHES="$ALL_BRANCHES $BATCH"
|
|
||||||
PAGE=$((PAGE + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
# Filter to cascade targets: dev, dev/*, rc/*, beta/*, alpha/*
|
|
||||||
TARGETS=""
|
|
||||||
for BRANCH in $ALL_BRANCHES; do
|
|
||||||
case "$BRANCH" in
|
|
||||||
dev|dev/*|rc/*|beta/*|alpha/*)
|
|
||||||
TARGETS="$TARGETS $BRANCH"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
TARGETS=$(echo "$TARGETS" | xargs) # trim whitespace
|
|
||||||
|
|
||||||
if [ -z "$TARGETS" ]; then
|
|
||||||
echo "targets=" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "ℹ️ No cascade target branches found"
|
|
||||||
else
|
|
||||||
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
|
|
||||||
COUNT=$(echo "$TARGETS" | wc -w)
|
|
||||||
echo "📋 Found ${COUNT} target branch(es): ${TARGETS}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Cascade to all target branches
|
|
||||||
if: steps.branches.outputs.targets != ''
|
|
||||||
env:
|
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
|
||||||
TARGETS="${{ steps.branches.outputs.targets }}"
|
|
||||||
|
|
||||||
SUCCESS=0
|
|
||||||
CONFLICTS=0
|
|
||||||
SKIPPED=0
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
for BRANCH in $TARGETS; do
|
|
||||||
echo ""
|
|
||||||
echo "═══ main → ${BRANCH} ═══"
|
|
||||||
|
|
||||||
# Check if branch is already up to date
|
|
||||||
ENCODED_BRANCH=$(echo "$BRANCH" | sed 's|/|%2F|g')
|
|
||||||
RESPONSE=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API}/compare/${ENCODED_BRANCH}...main")
|
|
||||||
|
|
||||||
AHEAD=$(echo "$RESPONSE" | jq '.total_commits // 0')
|
|
||||||
|
|
||||||
if [ "$AHEAD" -eq 0 ]; then
|
|
||||||
echo " ✅ Already up to date"
|
|
||||||
SKIPPED=$((SKIPPED + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " ℹ️ main is ${AHEAD} commit(s) ahead"
|
|
||||||
|
|
||||||
# Check for existing cascade PR
|
|
||||||
EXISTING=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API}/pulls?state=open&head=${GITEA_ORG}:main&base=${ENCODED_BRANCH}&limit=1")
|
|
||||||
|
|
||||||
EXISTING_COUNT=$(echo "$EXISTING" | jq 'length')
|
|
||||||
PR_NUMBER=""
|
|
||||||
|
|
||||||
if [ "$EXISTING_COUNT" -gt 0 ]; then
|
|
||||||
PR_NUMBER=$(echo "$EXISTING" | jq -r '.[0].number')
|
|
||||||
echo " ℹ️ Reusing existing PR #${PR_NUMBER}"
|
|
||||||
else
|
|
||||||
# Create cascade PR
|
|
||||||
PR_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"title\": \"chore: cascade main → ${BRANCH} (${SHORT_SHA}) [skip ci]\",
|
|
||||||
\"body\": \"## Automatic cascade\\n\\nForward-merging \`main\` (${SHORT_SHA}) into \`${BRANCH}\`.\\n\\nIf conflicts exist, resolve manually and merge.\\n\\n> Auto-created by **Cascade Main → Dev**.\",
|
|
||||||
\"head\": \"main\",
|
|
||||||
\"base\": \"${BRANCH}\"
|
|
||||||
}" \
|
|
||||||
"${API}/pulls")
|
|
||||||
|
|
||||||
HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
|
||||||
BODY=$(echo "$PR_RESPONSE" | sed '$d')
|
|
||||||
PR_NUMBER=$(echo "$BODY" | jq -r '.number // empty')
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" != "201" ] || [ -z "$PR_NUMBER" ]; then
|
|
||||||
MSG=$(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)
|
|
||||||
echo " ❌ Failed to create PR (HTTP ${HTTP_CODE}): ${MSG}"
|
|
||||||
FAILED=$((FAILED + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " ✅ Created PR #${PR_NUMBER}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try auto-merge
|
|
||||||
PR_DATA=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API}/pulls/${PR_NUMBER}")
|
|
||||||
|
|
||||||
MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable // false')
|
|
||||||
|
|
||||||
if [ "$MERGEABLE" != "true" ]; then
|
|
||||||
echo " ⚠️ Conflicts — PR #${PR_NUMBER} left open"
|
|
||||||
CONFLICTS=$((CONFLICTS + 1))
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
MERGE_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"Do\": \"merge\",
|
|
||||||
\"merge_message_field\": \"chore: cascade main → ${BRANCH} [skip ci]\",
|
|
||||||
\"delete_branch_after_merge\": false
|
|
||||||
}" \
|
|
||||||
"${API}/pulls/${PR_NUMBER}/merge")
|
|
||||||
|
|
||||||
MERGE_HTTP=$(echo "$MERGE_RESPONSE" | tail -1)
|
|
||||||
|
|
||||||
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
|
||||||
echo " ✅ Merged — ${BRANCH} is in sync"
|
|
||||||
SUCCESS=$((SUCCESS + 1))
|
|
||||||
else
|
|
||||||
MERGE_BODY=$(echo "$MERGE_RESPONSE" | sed '$d')
|
|
||||||
echo " ⚠️ Merge failed (HTTP ${MERGE_HTTP}) — PR #${PR_NUMBER} left open"
|
|
||||||
CONFLICTS=$((CONFLICTS + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
echo ""
|
|
||||||
echo "════════════════════════════════════════"
|
|
||||||
echo " ✅ Merged: ${SUCCESS}"
|
|
||||||
echo " ⚠️ Conflicts: ${CONFLICTS}"
|
|
||||||
echo " ⏭️ Up to date: ${SKIPPED}"
|
|
||||||
echo " ❌ Failed: ${FAILED}"
|
|
||||||
echo "════════════════════════════════════════"
|
|
||||||
|
|
||||||
if [ "$FAILED" -gt 0 ]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -43,9 +43,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Clone MokoStandards
|
- name: Clone MokoStandards
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || secrets.GA_TOKEN || github.token }}
|
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }}
|
||||||
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
|
||||||
run: |
|
run: |
|
||||||
git clone --depth 1 --branch main --quiet \
|
git clone --depth 1 --branch main --quiet \
|
||||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
run: |
|
run: |
|
||||||
if [ -f "composer.json" ]; then
|
if [ -f "composer.json" ]; then
|
||||||
composer install \
|
composer install \
|
||||||
@@ -346,7 +346,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
run: |
|
run: |
|
||||||
if [ -f "composer.json" ]; then
|
if [ -f "composer.json" ]; then
|
||||||
composer install \
|
composer install \
|
||||||
@@ -391,7 +391,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GA_TOKEN || github.token }}"}}'
|
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
|
||||||
run: |
|
run: |
|
||||||
if [ -f "composer.json" ]; then
|
if [ -f "composer.json" ]; then
|
||||||
composer install --no-interaction --prefer-dist --optimize-autoloader
|
composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.Maintenance
|
# INGROUP: moko-platform.Maintenance
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/cleanup.yml
|
# PATH: /.gitea/workflows/cleanup.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
|
||||||
@@ -33,17 +33,17 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
token: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
|
||||||
- name: Delete merged branches
|
- name: Delete merged branches
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Merged Branch Cleanup ==="
|
echo "=== Merged Branch Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
|
|
||||||
# List branches via API
|
# List branches via API
|
||||||
BRANCHES=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API}/branches?limit=50" | jq -r '.[].name')
|
"${API}/branches?limit=50" | jq -r '.[].name')
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
# Check if branch is merged into main
|
# Check if branch is merged into main
|
||||||
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then
|
||||||
echo " Deleting merged branch: ${BRANCH}"
|
echo " Deleting merged branch: ${BRANCH}"
|
||||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
"${API}/branches/${BRANCH}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
fi
|
fi
|
||||||
@@ -66,20 +66,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Clean old workflow runs
|
- name: Clean old workflow runs
|
||||||
env:
|
env:
|
||||||
GA_TOKEN: ${{ secrets.GA_TOKEN }}
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
echo "=== Workflow Run Cleanup ==="
|
echo "=== Workflow Run Cleanup ==="
|
||||||
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
|
||||||
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
|
||||||
# Get old completed runs
|
# Get old completed runs
|
||||||
RUNS=$(curl -sS -H "Authorization: token ${GA_TOKEN}" \
|
RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API}/actions/runs?status=completed&limit=50" | \
|
"${API}/actions/runs?status=completed&limit=50" | \
|
||||||
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null)
|
||||||
|
|
||||||
DELETED=0
|
DELETED=0
|
||||||
for RUN_ID in $RUNS; do
|
for RUN_ID in $RUNS; do
|
||||||
curl -sS -X DELETE -H "Authorization: token ${GA_TOKEN}" \
|
curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
"${API}/actions/runs/${RUN_ID}" 2>/dev/null || true
|
||||||
DELETED=$((DELETED + 1))
|
DELETED=$((DELETED + 1))
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.Security
|
# INGROUP: moko-platform.Security
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
# PATH: /templates/workflows/gitleaks.yml.template
|
# PATH: /templates/workflows/gitleaks.yml.template
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.Notifications
|
# INGROUP: moko-platform.Notifications
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/notify.yml
|
# PATH: /.gitea/workflows/notify.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
# BRIEF: Push notifications via ntfy on release success or workflow failure
|
||||||
@@ -18,7 +18,6 @@ on:
|
|||||||
- "Joomla Build & Release"
|
- "Joomla Build & Release"
|
||||||
- "Joomla Extension CI"
|
- "Joomla Extension CI"
|
||||||
- "Deploy"
|
- "Deploy"
|
||||||
- "Cascade Main → Dev"
|
|
||||||
types:
|
types:
|
||||||
- completed
|
- completed
|
||||||
|
|
||||||
|
|||||||
+277
-194
@@ -1,194 +1,277 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.CI
|
# INGROUP: moko-platform.CI
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
|
||||||
# PATH: /templates/workflows/universal/pr-check.yml.template
|
# PATH: /templates/workflows/universal/pr-check.yml.template
|
||||||
# VERSION: 05.00.00
|
# VERSION: 09.23.00
|
||||||
# BRIEF: PR gate — branch policy + code validation before merge
|
# BRIEF: PR gate — branch policy + code validation before merge
|
||||||
|
|
||||||
name: "Universal: PR Check"
|
name: "Universal: PR Check"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, edited]
|
types: [opened, synchronize, reopened, edited]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Branch Policy ──────────────────────────────────────────────────────
|
# ── Branch Policy ──────────────────────────────────────────────────────
|
||||||
branch-policy:
|
branch-policy:
|
||||||
name: Branch Policy
|
name: Branch Policy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check branch merge target
|
- name: Check branch merge target
|
||||||
run: |
|
run: |
|
||||||
HEAD="${{ github.head_ref }}"
|
HEAD="${{ github.head_ref }}"
|
||||||
BASE="${{ github.base_ref }}"
|
BASE="${{ github.base_ref }}"
|
||||||
|
|
||||||
echo "PR: ${HEAD} → ${BASE}"
|
echo "PR: ${HEAD} → ${BASE}"
|
||||||
|
|
||||||
ALLOWED=true
|
ALLOWED=true
|
||||||
REASON=""
|
REASON=""
|
||||||
|
|
||||||
case "$HEAD" in
|
case "$HEAD" in
|
||||||
feature/*|feat/*)
|
feature/*|feat/*)
|
||||||
if [ "$BASE" != "dev" ]; then
|
if [ "$BASE" != "dev" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Feature branches must target 'dev', not '${BASE}'"
|
REASON="Feature branches must target 'dev', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
fix/*|bugfix/*)
|
fix/*|bugfix/*)
|
||||||
if [ "$BASE" != "dev" ]; then
|
if [ "$BASE" != "dev" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Fix branches must target 'dev', not '${BASE}'"
|
REASON="Fix branches must target 'dev', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
hotfix/*)
|
patch/*)
|
||||||
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
alpha/*|beta/*)
|
hotfix/*)
|
||||||
if [ "$BASE" != "dev" ]; then
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Pre-release branches must target 'dev', not '${BASE}'"
|
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
rc/*)
|
rc)
|
||||||
if [ "$BASE" != "main" ]; then
|
if [ "$BASE" != "main" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Release candidate branches must target 'main', not '${BASE}'"
|
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
dev)
|
dev)
|
||||||
if [ "$BASE" != "main" ]; then
|
if [ "$BASE" != "main" ]; then
|
||||||
ALLOWED=false
|
ALLOWED=false
|
||||||
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [ "$ALLOWED" = false ]; then
|
if [ "$ALLOWED" = false ]; then
|
||||||
echo "::error::${REASON}"
|
echo "::error::${REASON}"
|
||||||
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
||||||
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# ── Code Validation ────────────────────────────────────────────────────
|
# ── Code Validation ────────────────────────────────────────────────────
|
||||||
validate:
|
validate:
|
||||||
name: Validate PR
|
name: Validate PR
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Detect platform
|
- name: Check for merge conflict markers
|
||||||
id: platform
|
run: |
|
||||||
run: |
|
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
||||||
PLATFORM=$(cat .mokogitea/.moko-platform 2>/dev/null | tr -d '[:space:]')
|
if [ -n "$CONFLICTS" ]; then
|
||||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
echo "::error::Merge conflict markers found in source files"
|
||||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
- name: Setup PHP
|
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
||||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
run: |
|
exit 1
|
||||||
if ! command -v php &> /dev/null; then
|
fi
|
||||||
sudo apt-get update -qq
|
echo "No conflict markers found"
|
||||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
|
||||||
fi
|
- name: Detect platform
|
||||||
|
id: platform
|
||||||
- name: PHP syntax check
|
run: |
|
||||||
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
# Read platform from XML manifest (<platform> tag) or plain text fallback
|
||||||
run: |
|
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
|
||||||
ERRORS=0
|
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
|
||||||
while IFS= read -r -d '' file; do
|
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
||||||
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
||||||
ERRORS=$((ERRORS + 1))
|
|
||||||
fi
|
- name: Setup PHP
|
||||||
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||||
echo "PHP lint: ${ERRORS} error(s)"
|
run: |
|
||||||
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
if ! command -v php &> /dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
- name: Validate platform manifest
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
||||||
run: |
|
fi
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
|
||||||
case "$PLATFORM" in
|
- name: PHP syntax check
|
||||||
joomla)
|
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
||||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
run: |
|
||||||
if [ -z "$MANIFEST" ]; then
|
ERRORS=0
|
||||||
echo "::warning::No Joomla manifest found (WaaS site)"
|
while IFS= read -r -d '' file; do
|
||||||
exit 0
|
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
||||||
fi
|
ERRORS=$((ERRORS + 1))
|
||||||
echo "Manifest: ${MANIFEST}"
|
fi
|
||||||
if command -v php &> /dev/null; then
|
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
||||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
echo "PHP lint: ${ERRORS} error(s)"
|
||||||
fi
|
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
||||||
for ELEMENT in name version description; do
|
|
||||||
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
- name: Validate platform manifest
|
||||||
done
|
run: |
|
||||||
echo "Joomla manifest valid"
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
;;
|
case "$PLATFORM" in
|
||||||
dolibarr)
|
joomla)
|
||||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||||
if [ -z "$MOD_FILE" ]; then
|
if [ -z "$MANIFEST" ]; then
|
||||||
echo "::error::No mod*.class.php found"
|
echo "::warning::No Joomla manifest found (WaaS site)"
|
||||||
exit 1
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Dolibarr module: ${MOD_FILE}"
|
echo "Manifest: ${MANIFEST}"
|
||||||
;;
|
if command -v php &> /dev/null; then
|
||||||
*)
|
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
||||||
echo "Generic platform — no manifest validation"
|
fi
|
||||||
;;
|
for ELEMENT in name version description; do
|
||||||
esac
|
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
||||||
|
done
|
||||||
- name: Check update stream format
|
echo "Joomla manifest valid"
|
||||||
run: |
|
;;
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
dolibarr)
|
||||||
case "$PLATFORM" in
|
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
||||||
joomla)
|
if [ -z "$MOD_FILE" ]; then
|
||||||
if [ -f "updates.xml" ]; then
|
echo "::error::No mod*.class.php found"
|
||||||
if command -v php &> /dev/null; then
|
exit 1
|
||||||
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
fi
|
||||||
fi
|
echo "Dolibarr module: ${MOD_FILE}"
|
||||||
echo "updates.xml valid"
|
;;
|
||||||
fi
|
*)
|
||||||
;;
|
echo "Generic platform — no manifest validation"
|
||||||
dolibarr)
|
;;
|
||||||
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
esac
|
||||||
;;
|
|
||||||
esac
|
- name: Check update stream format
|
||||||
|
run: |
|
||||||
- name: Verify package source
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
||||||
run: |
|
case "$PLATFORM" in
|
||||||
SOURCE_DIR="src"
|
joomla)
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
if [ -f "updates.xml" ]; then
|
||||||
if [ ! -d "$SOURCE_DIR" ]; then
|
if command -v php &> /dev/null; then
|
||||||
echo "::warning::No src/ or htdocs/ directory"
|
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
||||||
exit 0
|
fi
|
||||||
fi
|
echo "updates.xml valid"
|
||||||
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
fi
|
||||||
echo "Source: ${FILE_COUNT} files"
|
;;
|
||||||
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
dolibarr)
|
||||||
|
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
- name: Check changelog has unreleased entry
|
||||||
|
run: |
|
||||||
|
if [ ! -f "CHANGELOG.md" ]; then
|
||||||
|
echo "::warning::No CHANGELOG.md found"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# Check for content under [Unreleased] section
|
||||||
|
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
||||||
|
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
||||||
|
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
||||||
|
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
||||||
|
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
||||||
|
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
||||||
|
|
||||||
|
- name: Verify package source
|
||||||
|
run: |
|
||||||
|
SOURCE_DIR="src"
|
||||||
|
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
||||||
|
if [ ! -d "$SOURCE_DIR" ]; then
|
||||||
|
echo "::warning::No src/ or htdocs/ directory"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
||||||
|
echo "Source: ${FILE_COUNT} files"
|
||||||
|
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
||||||
|
|
||||||
|
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
||||||
|
pre-release:
|
||||||
|
name: Build RC Package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [branch-policy, validate]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Trigger RC pre-release
|
||||||
|
env:
|
||||||
|
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BRANCH: ${{ github.head_ref }}
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
run: |
|
||||||
|
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
||||||
|
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# ── Issue Reporter ──────────────────────────────────────────────────────
|
||||||
|
report-issues:
|
||||||
|
name: Report Issues
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [branch-policy, validate]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
needs.validate.result == 'failure'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: automation/ci-issue-reporter.sh
|
||||||
|
sparse-checkout-cone-mode: false
|
||||||
|
|
||||||
|
- name: "File issue for PR validation failure"
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||||
|
run: |
|
||||||
|
chmod +x automation/ci-issue-reporter.sh
|
||||||
|
./automation/ci-issue-reporter.sh \
|
||||||
|
--gate "PR Validation" \
|
||||||
|
--workflow "PR Check" \
|
||||||
|
--severity error \
|
||||||
|
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: moko-platform.Release
|
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
|
||||||
# PATH: /templates/workflows/universal/pre-release.yml.template
|
|
||||||
# VERSION: 05.00.00
|
|
||||||
# BRIEF: Manual pre-release — builds dev/alpha/beta/rc packages from any branch
|
|
||||||
|
|
||||||
name: "Universal: Pre-Release"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
stability:
|
|
||||||
description: 'Pre-release channel'
|
|
||||||
required: true
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- development
|
|
||||||
- alpha
|
|
||||||
- beta
|
|
||||||
- release-candidate
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: "Build Pre-Release (${{ inputs.stability }})"
|
|
||||||
runs-on: release
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
run: |
|
|
||||||
if ! command -v php &> /dev/null; then
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Detect platform
|
|
||||||
id: platform
|
|
||||||
run: |
|
|
||||||
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1 | tr -d '[:space:]')
|
|
||||||
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
|
||||||
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
|
||||||
# For packages: prefer pkg_*.xml in src/; fallback to any manifest
|
|
||||||
MANIFEST=$(find ./src -maxdepth 1 -name "pkg_*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
|
||||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "*/packages/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
|
||||||
[ -z "$MANIFEST" ] && MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
|
||||||
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
|
||||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "mod_file=${MOD_FILE}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Resolve metadata
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
STABILITY="${{ inputs.stability }}"
|
|
||||||
|
|
||||||
case "$STABILITY" in
|
|
||||||
development) SUFFIX="-dev"; TAG="development" ;;
|
|
||||||
alpha) SUFFIX="-alpha"; TAG="alpha" ;;
|
|
||||||
beta) SUFFIX="-beta"; TAG="beta" ;;
|
|
||||||
release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Read and bump patch version (with rollover)
|
|
||||||
CURRENT=$(sed -n 's/.*VERSION:[[:space:]]*\([0-9][0-9]\.[0-9][0-9]\.[0-9][0-9]\).*/\1/p' README.md 2>/dev/null | head -1)
|
|
||||||
[ -z "$CURRENT" ] && CURRENT="00.00.00"
|
|
||||||
|
|
||||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
|
||||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
|
||||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
|
||||||
|
|
||||||
# Patch bump with rollover: ZZ=99 → bump minor, YY=99 → bump major
|
|
||||||
NEW_PATCH=$((10#$PATCH + 1))
|
|
||||||
NEW_MINOR=$((10#$MINOR))
|
|
||||||
NEW_MAJOR=$((10#$MAJOR))
|
|
||||||
|
|
||||||
if [ $NEW_PATCH -gt 99 ]; then
|
|
||||||
NEW_PATCH=0
|
|
||||||
NEW_MINOR=$((NEW_MINOR + 1))
|
|
||||||
fi
|
|
||||||
if [ $NEW_MINOR -gt 99 ]; then
|
|
||||||
NEW_MINOR=0
|
|
||||||
NEW_MAJOR=$((NEW_MAJOR + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION=$(printf "%02d.%02d.%02d" $NEW_MAJOR $NEW_MINOR $NEW_PATCH)
|
|
||||||
TODAY=$(date +%Y-%m-%d)
|
|
||||||
|
|
||||||
echo "Bumping: ${CURRENT} → ${VERSION} (patch)"
|
|
||||||
|
|
||||||
# Update README.md
|
|
||||||
sed -i "s/VERSION:[[:space:]]*${CURRENT}/VERSION: ${VERSION}/" README.md
|
|
||||||
|
|
||||||
# Update platform-specific manifest
|
|
||||||
PLATFORM="${{ steps.platform.outputs.platform }}"
|
|
||||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
|
||||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
|
||||||
case "$PLATFORM" in
|
|
||||||
joomla)
|
|
||||||
if [ -n "$MANIFEST" ]; then
|
|
||||||
MANIFEST_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
sed -i "s|<version>${MANIFEST_VER}</version>|<version>${VERSION}</version>|" "$MANIFEST"
|
|
||||||
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$MANIFEST"
|
|
||||||
fi
|
|
||||||
# For packages: also bump version in all sub-extension manifests
|
|
||||||
if [ -d "src/packages" ]; then
|
|
||||||
for SUB_MANIFEST in $(find src/packages -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null); do
|
|
||||||
SUB_VER=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$SUB_MANIFEST" | head -1)
|
|
||||||
if [ -n "$SUB_VER" ]; then
|
|
||||||
sed -i "s|<version>${SUB_VER}</version>|<version>${VERSION}</version>|" "$SUB_MANIFEST"
|
|
||||||
sed -i "s|<creationDate>[^<]*</creationDate>|<creationDate>${TODAY}</creationDate>|" "$SUB_MANIFEST"
|
|
||||||
echo " Bumped sub-extension: $(basename $SUB_MANIFEST) ${SUB_VER} → ${VERSION}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
dolibarr)
|
|
||||||
if [ -n "$MOD_FILE" ]; then
|
|
||||||
sed -i "s/\$this->version = '[^']*'/\$this->version = '${VERSION}'/" "$MOD_FILE"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*) ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Commit version bump
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
git remote set-url origin "https://jmiller:${{ secrets.GA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
|
|
||||||
git add -A
|
|
||||||
git diff --cached --quiet || {
|
|
||||||
git commit -m "chore(version): bump ${CURRENT} → ${VERSION} [skip ci]"
|
|
||||||
git push origin HEAD 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Auto-detect element (platform-aware)
|
|
||||||
case "$PLATFORM" in
|
|
||||||
joomla)
|
|
||||||
MANIFEST="${{ steps.platform.outputs.manifest }}"
|
|
||||||
EXT_ELEMENT=""
|
|
||||||
if [ -n "$MANIFEST" ]; then
|
|
||||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" 2>/dev/null | head -1)
|
|
||||||
if [ -z "$EXT_ELEMENT" ]; then
|
|
||||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
|
||||||
case "$EXT_ELEMENT" in
|
|
||||||
templatedetails|manifest) EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
dolibarr)
|
|
||||||
MOD_FILE="${{ steps.platform.outputs.mod_file }}"
|
|
||||||
if [ -n "$MOD_FILE" ]; then
|
|
||||||
MOD_BASENAME=$(basename "$MOD_FILE" .class.php)
|
|
||||||
EXT_ELEMENT=$(echo "$MOD_BASENAME" | sed 's/^mod//' | tr '[:upper:]' '[:lower:]')
|
|
||||||
else
|
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
ZIP_NAME="${EXT_ELEMENT}-${VERSION}${SUFFIX}.zip"
|
|
||||||
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "manifest=${MANIFEST}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ==="
|
|
||||||
|
|
||||||
- name: Build package
|
|
||||||
run: |
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
if [ ! -d "$SOURCE_DIR" ]; then
|
|
||||||
echo "::error::No src/ or htdocs/ directory"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
MANIFEST="${{ steps.meta.outputs.manifest }}"
|
|
||||||
EXT_TYPE=""
|
|
||||||
if [ -n "$MANIFEST" ]; then
|
|
||||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
fi
|
|
||||||
|
|
||||||
EXCLUDES="sftp-config* .ftpignore *.ppk *.pem *.key .env* *.local .build-trigger"
|
|
||||||
|
|
||||||
mkdir -p build/package
|
|
||||||
|
|
||||||
if [ "$EXT_TYPE" = "package" ] && [ -d "${SOURCE_DIR}/packages" ]; then
|
|
||||||
echo "=== Building Joomla PACKAGE (multi-extension) ==="
|
|
||||||
|
|
||||||
# 1) ZIP each sub-extension in src/packages/
|
|
||||||
for ext_dir in "${SOURCE_DIR}"/packages/*/; do
|
|
||||||
[ ! -d "$ext_dir" ] && continue
|
|
||||||
EXT_NAME=$(basename "$ext_dir")
|
|
||||||
echo " Packaging sub-extension: ${EXT_NAME}"
|
|
||||||
cd "$ext_dir"
|
|
||||||
zip -r "../../build/package/${EXT_NAME}.zip" . -x $EXCLUDES
|
|
||||||
cd "$OLDPWD"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 2) Copy package-level files (manifest, script, etc.)
|
|
||||||
for f in "${SOURCE_DIR}"/*.xml "${SOURCE_DIR}"/*.php; do
|
|
||||||
[ -f "$f" ] && cp "$f" build/package/
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Package contents:"
|
|
||||||
ls -la build/package/
|
|
||||||
else
|
|
||||||
echo "=== Building standard Joomla extension ==="
|
|
||||||
rsync -a \
|
|
||||||
--exclude='sftp-config*' \
|
|
||||||
--exclude='.ftpignore' \
|
|
||||||
--exclude='*.ppk' \
|
|
||||||
--exclude='*.pem' \
|
|
||||||
--exclude='*.key' \
|
|
||||||
--exclude='.env*' \
|
|
||||||
--exclude='*.local' \
|
|
||||||
--exclude='.build-trigger' \
|
|
||||||
"${SOURCE_DIR}/" build/package/
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create ZIP
|
|
||||||
id: zip
|
|
||||||
run: |
|
|
||||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
|
||||||
cd build/package
|
|
||||||
zip -r "../${ZIP_NAME}" .
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
SHA256=$(sha256sum "${ZIP_NAME}" | cut -d' ' -f1)
|
|
||||||
echo "sha256=${SHA256}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "ZIP: ${ZIP_NAME} (SHA: ${SHA256:0:16}...)"
|
|
||||||
|
|
||||||
- name: Create or replace Gitea release
|
|
||||||
id: release
|
|
||||||
run: |
|
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
|
||||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
|
||||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
|
||||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
|
||||||
EXT_ELEMENT="${{ steps.meta.outputs.ext_element }}"
|
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
|
||||||
API="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
BRANCH=$(git branch --show-current)
|
|
||||||
|
|
||||||
BODY="## ${VERSION} ($(date +%Y-%m-%d))
|
|
||||||
**Channel:** ${STABILITY}
|
|
||||||
**SHA-256:** \`${SHA256}\`"
|
|
||||||
|
|
||||||
# Delete existing release
|
|
||||||
EXISTING_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API}/releases/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null)
|
|
||||||
if [ -n "$EXISTING_ID" ]; then
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API}/releases/${EXISTING_ID}" 2>/dev/null || true
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API}/tags/${TAG}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create release
|
|
||||||
RELEASE_ID=$(curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/releases" \
|
|
||||||
-d "$(jq -n \
|
|
||||||
--arg tag "$TAG" \
|
|
||||||
--arg target "$BRANCH" \
|
|
||||||
--arg name "${EXT_ELEMENT} ${VERSION} (${STABILITY})" \
|
|
||||||
--arg body "$BODY" \
|
|
||||||
'{tag_name: $tag, target_commitish: $target, name: $name, body: $body, prerelease: true}'
|
|
||||||
)" | jq -r '.id')
|
|
||||||
|
|
||||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Upload ZIP
|
|
||||||
curl -sS -X POST -H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
"${API}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
|
|
||||||
--data-binary "@build/${ZIP_NAME}"
|
|
||||||
|
|
||||||
echo "Released: ${EXT_ELEMENT} ${VERSION} (${STABILITY})"
|
|
||||||
|
|
||||||
- name: Update updates.xml
|
|
||||||
if: steps.platform.outputs.platform == 'joomla'
|
|
||||||
run: |
|
|
||||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
|
||||||
VERSION="${{ steps.meta.outputs.version }}"
|
|
||||||
SHA256="${{ steps.zip.outputs.sha256 }}"
|
|
||||||
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
|
|
||||||
TAG="${{ steps.meta.outputs.tag }}"
|
|
||||||
DATE=$(date +%Y-%m-%d)
|
|
||||||
|
|
||||||
if [ ! -f "updates.xml" ]; then
|
|
||||||
echo "No updates.xml — skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
export PY_STABILITY="$STABILITY" PY_VERSION="$VERSION" PY_SHA256="$SHA256" \
|
|
||||||
PY_ZIP_NAME="$ZIP_NAME" PY_TAG="$TAG" PY_DATE="$DATE" \
|
|
||||||
PY_GITEA_ORG="$GITEA_ORG" PY_GITEA_REPO="$GITEA_REPO"
|
|
||||||
python3 << 'PYEOF'
|
|
||||||
import re, os
|
|
||||||
|
|
||||||
stability = os.environ["PY_STABILITY"]
|
|
||||||
version = os.environ["PY_VERSION"]
|
|
||||||
sha256 = os.environ["PY_SHA256"]
|
|
||||||
zip_name = os.environ["PY_ZIP_NAME"]
|
|
||||||
tag = os.environ["PY_TAG"]
|
|
||||||
date = os.environ["PY_DATE"]
|
|
||||||
gitea_org = os.environ["PY_GITEA_ORG"]
|
|
||||||
gitea_repo = os.environ["PY_GITEA_REPO"]
|
|
||||||
download_url = f"https://git.mokoconsulting.tech/{gitea_org}/{gitea_repo}/releases/download/{tag}/{zip_name}"
|
|
||||||
|
|
||||||
with open("updates.xml", "r") as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Map stability to XML tag name
|
|
||||||
tag_map = {"development": "development", "alpha": "alpha", "beta": "beta", "release-candidate": "rc"}
|
|
||||||
xml_tag = tag_map.get(stability, stability)
|
|
||||||
|
|
||||||
pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(xml_tag) + r"</tag>.*?</update>)"
|
|
||||||
match = re.search(pattern, content, re.DOTALL)
|
|
||||||
if match:
|
|
||||||
block = match.group(1)
|
|
||||||
updated = re.sub(r"<version>[^<]*</version>", f"<version>{version}</version>", block)
|
|
||||||
updated = re.sub(r"<creationDate>[^<]*</creationDate>", f"<creationDate>{date}</creationDate>", updated)
|
|
||||||
if "<sha256>" in updated:
|
|
||||||
updated = re.sub(r"<sha256>[^<]*</sha256>", f"<sha256>{sha256}</sha256>", updated)
|
|
||||||
else:
|
|
||||||
updated = updated.replace("</downloads>", f"</downloads>\n <sha256>{sha256}</sha256>")
|
|
||||||
updated = re.sub(r"(<downloadurl[^>]*>)[^<]*(</downloadurl>)", rf"\g<1>{download_url}\g<2>", updated)
|
|
||||||
content = content.replace(block, updated)
|
|
||||||
print(f"Updated {xml_tag} channel: version={version}")
|
|
||||||
else:
|
|
||||||
print(f"WARNING: No <tag>{xml_tag}</tag> block in updates.xml")
|
|
||||||
|
|
||||||
with open("updates.xml", "w") as f:
|
|
||||||
f.write(content)
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
# Commit and push to current branch
|
|
||||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
git add updates.xml
|
|
||||||
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
|
|
||||||
git push origin HEAD 2>&1 || echo "WARNING: push failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: "Sync updates.xml to all branches"
|
|
||||||
if: steps.platform.outputs.platform == 'joomla'
|
|
||||||
run: |
|
|
||||||
CURRENT_BRANCH="${{ github.ref_name }}"
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
|
|
||||||
# Sync updates.xml to main and dev (whichever isn't current)
|
|
||||||
for BRANCH in main dev; do
|
|
||||||
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
|
|
||||||
|
|
||||||
echo "Syncing updates.xml → ${BRANCH}"
|
|
||||||
git fetch origin "${BRANCH}" 2>/dev/null || continue
|
|
||||||
git checkout "origin/${BRANCH}" -- . 2>/dev/null || continue
|
|
||||||
git checkout "${CURRENT_BRANCH}" -- updates.xml
|
|
||||||
if ! git diff --quiet updates.xml 2>/dev/null; then
|
|
||||||
git add updates.xml
|
|
||||||
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
|
|
||||||
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
|
|
||||||
fi
|
|
||||||
git checkout "${CURRENT_BRANCH}" 2>/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: "Delete lesser pre-release channels (cascade)"
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
TOKEN="${{ secrets.GA_TOKEN }}"
|
|
||||||
STABILITY="${{ steps.meta.outputs.stability }}"
|
|
||||||
|
|
||||||
# Cascade: rc → beta,alpha,dev | beta → alpha,dev | alpha → dev | dev → nothing
|
|
||||||
case "$STABILITY" in
|
|
||||||
release-candidate) TAGS_TO_DELETE="beta alpha development" ;;
|
|
||||||
beta) TAGS_TO_DELETE="alpha development" ;;
|
|
||||||
alpha) TAGS_TO_DELETE="development" ;;
|
|
||||||
*) TAGS_TO_DELETE="" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[ -z "$TAGS_TO_DELETE" ] && exit 0
|
|
||||||
|
|
||||||
for TAG in $TAGS_TO_DELETE; do
|
|
||||||
RELEASE_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API_BASE}/releases/tags/${TAG}" 2>/dev/null | \
|
|
||||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "None" ]; then
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}" 2>/dev/null || true
|
|
||||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
|
||||||
"${API_BASE}/tags/${TAG}" 2>/dev/null || true
|
|
||||||
echo "Deleted: ${TAG} (id: ${RELEASE_ID})"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@
|
|||||||
#
|
#
|
||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: MokoStandards.Security
|
# INGROUP: moko-platform.Security
|
||||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
# PATH: /.gitea/workflows/security-audit.yml
|
# PATH: /.gitea/workflows/security-audit.yml
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.00.00
|
||||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||||
@@ -80,3 +80,19 @@ jobs:
|
|||||||
-H "Priority: high" \
|
-H "Priority: high" \
|
||||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||||
|
|
||||||
|
|
||||||
|
- name: Joomla version audit
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then
|
||||||
|
echo "$JOOMLA_SITES" > /tmp/sites.json
|
||||||
|
php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true
|
||||||
|
echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY
|
||||||
|
rm -f /tmp/sites.json
|
||||||
|
else
|
||||||
|
echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)"
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,464 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: Gitea.Workflow
|
|
||||||
# INGROUP: MokoStandards.Joomla
|
|
||||||
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
|
|
||||||
# PATH: /templates/workflows/joomla/update-server.yml.template
|
|
||||||
# VERSION: 04.06.00
|
|
||||||
# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries
|
|
||||||
#
|
|
||||||
# Writes updates.xml with multiple <update> entries:
|
|
||||||
# - <tag>stable</tag> on push to main (from auto-release)
|
|
||||||
# - <tag>rc</tag> on push to rc/**
|
|
||||||
# - <tag>development</tag> on push to dev or dev/**
|
|
||||||
#
|
|
||||||
# Joomla filters by user's "Minimum Stability" setting.
|
|
||||||
|
|
||||||
name: "Joomla: Update Server"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- 'dev'
|
|
||||||
- 'dev/**'
|
|
||||||
- 'alpha/**'
|
|
||||||
- 'beta/**'
|
|
||||||
- 'rc/**'
|
|
||||||
paths:
|
|
||||||
- 'src/**'
|
|
||||||
- 'htdocs/**'
|
|
||||||
pull_request:
|
|
||||||
types: [closed]
|
|
||||||
branches:
|
|
||||||
- 'dev'
|
|
||||||
- 'dev/**'
|
|
||||||
- 'alpha/**'
|
|
||||||
- 'beta/**'
|
|
||||||
- 'rc/**'
|
|
||||||
paths:
|
|
||||||
- 'src/**'
|
|
||||||
- 'htdocs/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
stability:
|
|
||||||
description: 'Stability tag'
|
|
||||||
required: true
|
|
||||||
default: 'development'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- development
|
|
||||||
- alpha
|
|
||||||
- beta
|
|
||||||
- rc
|
|
||||||
- stable
|
|
||||||
|
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
||||||
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
|
|
||||||
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-xml:
|
|
||||||
name: Update updates.xml
|
|
||||||
runs-on: release
|
|
||||||
if: >-
|
|
||||||
github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' || github.event_name == 'push'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GA_TOKEN }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup MokoStandards tools
|
|
||||||
env:
|
|
||||||
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN }}
|
|
||||||
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
|
|
||||||
COMPOSER_AUTH: '{"http-basic":{"git.mokoconsulting.tech":{"username":"token","password":"${{ secrets.GA_TOKEN }}"}}}'
|
|
||||||
run: |
|
|
||||||
if ! command -v composer &> /dev/null; then
|
|
||||||
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
git clone --depth 1 --branch main --quiet \
|
|
||||||
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
|
|
||||||
/tmp/mokostandards-api 2>/dev/null || true
|
|
||||||
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
|
|
||||||
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate updates.xml entry
|
|
||||||
id: update
|
|
||||||
run: |
|
|
||||||
BRANCH="${{ github.ref_name }}"
|
|
||||||
REPO="${{ github.repository }}"
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0")
|
|
||||||
|
|
||||||
# Auto-bump patch on all branches (dev, alpha, beta, rc)
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
BUMPED=$(php /tmp/mokostandards-api/cli/version_bump.php --path . 2>/dev/null || true)
|
|
||||||
if [ -n "$BUMPED" ]; then
|
|
||||||
VERSION=$(php /tmp/mokostandards-api/cli/version_read.php --path . 2>/dev/null || echo "$VERSION")
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore(version): auto-bump patch ${VERSION} [skip ci]" \
|
|
||||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>" 2>/dev/null || true
|
|
||||||
git push 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Determine stability from branch or input
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
STABILITY="${{ inputs.stability }}"
|
|
||||||
elif [[ "$BRANCH" == rc/* ]]; then
|
|
||||||
STABILITY="rc"
|
|
||||||
elif [[ "$BRANCH" == beta/* ]]; then
|
|
||||||
STABILITY="beta"
|
|
||||||
elif [[ "$BRANCH" == alpha/* ]]; then
|
|
||||||
STABILITY="alpha"
|
|
||||||
elif [[ "$BRANCH" == dev/* ]] || [[ "$BRANCH" == "dev" ]]; then
|
|
||||||
STABILITY="development"
|
|
||||||
else
|
|
||||||
STABILITY="stable"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Parse manifest (portable — no grep -P)
|
|
||||||
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" ! -path "./build/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
|
||||||
if [ -z "$MANIFEST" ]; then
|
|
||||||
echo "No Joomla manifest found — skipping"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract fields using sed (works on all runners)
|
|
||||||
EXT_NAME=$(sed -n 's/.*<name>\([^<]*\)<\/name>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_TYPE=$(sed -n 's/.*<extension[^>]*type="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_ELEMENT=$(sed -n 's/.*<element>\([^<]*\)<\/element>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_CLIENT=$(sed -n 's/.*<extension[^>]*client="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_FOLDER=$(sed -n 's/.*<extension[^>]*group="\([^"]*\)".*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
EXT_VERSION=$(sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
TARGET_PLATFORM=$(sed -n 's/.*\(<targetplatform[^/]*\/>\).*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
PHP_MINIMUM=$(sed -n 's/.*<php_minimum>\([^<]*\)<\/php_minimum>.*/\1/p' "$MANIFEST" | head -1)
|
|
||||||
|
|
||||||
# Fallbacks
|
|
||||||
[ -z "$EXT_NAME" ] && EXT_NAME="${{ github.event.repository.name }}"
|
|
||||||
[ -z "$EXT_TYPE" ] && EXT_TYPE="component"
|
|
||||||
|
|
||||||
# Derive element if not in manifest: try XML filename, then repo name
|
|
||||||
if [ -z "$EXT_ELEMENT" ]; then
|
|
||||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml | tr '[:upper:]' '[:lower:]')
|
|
||||||
case "$EXT_ELEMENT" in
|
|
||||||
templatedetails|manifest|*.xml) EXT_ELEMENT=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Use manifest version if README version is empty
|
|
||||||
[ "$VERSION" = "0.0.0" ] && [ -n "$EXT_VERSION" ] && VERSION="$EXT_VERSION"
|
|
||||||
|
|
||||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="((5.[0-9])|(6.[0-9]))" %s>' "/")
|
|
||||||
|
|
||||||
CLIENT_TAG=""
|
|
||||||
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
|
||||||
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
|
|
||||||
|
|
||||||
FOLDER_TAG=""
|
|
||||||
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
|
||||||
|
|
||||||
PHP_TAG=""
|
|
||||||
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
|
||||||
|
|
||||||
# Version suffix for non-stable
|
|
||||||
DISPLAY_VERSION="$VERSION"
|
|
||||||
case "$STABILITY" in
|
|
||||||
development) DISPLAY_VERSION="${VERSION}-dev" ;;
|
|
||||||
alpha) DISPLAY_VERSION="${VERSION}-alpha" ;;
|
|
||||||
beta) DISPLAY_VERSION="${VERSION}-beta" ;;
|
|
||||||
rc) DISPLAY_VERSION="${VERSION}-rc" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
|
|
||||||
|
|
||||||
# Each stability level has its own release tag
|
|
||||||
case "$STABILITY" in
|
|
||||||
development) RELEASE_TAG="development" ;;
|
|
||||||
alpha) RELEASE_TAG="alpha" ;;
|
|
||||||
beta) RELEASE_TAG="beta" ;;
|
|
||||||
rc) RELEASE_TAG="release-candidate" ;;
|
|
||||||
*) RELEASE_TAG="v${MAJOR}" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
PACKAGE_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.zip"
|
|
||||||
DOWNLOAD_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
|
|
||||||
INFO_URL="${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
|
|
||||||
# -- Build install packages (ZIP + tar.gz) --------------------
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
if [ -d "$SOURCE_DIR" ]; then
|
|
||||||
EXCLUDES=".ftpignore sftp-config* *.ppk *.pem *.key .env*"
|
|
||||||
TAR_NAME="${EXT_ELEMENT}-${DISPLAY_VERSION}.tar.gz"
|
|
||||||
|
|
||||||
cd "$SOURCE_DIR"
|
|
||||||
zip -r "/tmp/${PACKAGE_NAME}" . -x $EXCLUDES
|
|
||||||
cd ..
|
|
||||||
tar -czf "/tmp/${TAR_NAME}" -C "$SOURCE_DIR" \
|
|
||||||
--exclude='.ftpignore' --exclude='sftp-config*' \
|
|
||||||
--exclude='*.ppk' --exclude='*.pem' --exclude='*.key' --exclude='.env*' .
|
|
||||||
|
|
||||||
SHA256=$(sha256sum "/tmp/${PACKAGE_NAME}" | cut -d' ' -f1)
|
|
||||||
|
|
||||||
# Ensure release exists on Gitea
|
|
||||||
RELEASE_JSON=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/tags/${RELEASE_TAG}" 2>/dev/null || true)
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
# Create release
|
|
||||||
RELEASE_JSON=$(curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API_BASE}/releases" \
|
|
||||||
-d "$(python3 -c "import json; print(json.dumps({
|
|
||||||
'tag_name': '${RELEASE_TAG}',
|
|
||||||
'name': '${RELEASE_TAG} (${DISPLAY_VERSION})',
|
|
||||||
'body': '${STABILITY} release',
|
|
||||||
'prerelease': True,
|
|
||||||
'target_commitish': 'main'
|
|
||||||
}))")" 2>/dev/null || true)
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$RELEASE_ID" ]; then
|
|
||||||
# Delete existing assets with same name before uploading
|
|
||||||
ASSETS=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets" 2>/dev/null || echo "[]")
|
|
||||||
for ASSET_FILE in "$PACKAGE_NAME" "$TAR_NAME"; do
|
|
||||||
ASSET_ID=$(echo "$ASSETS" | python3 -c "
|
|
||||||
import sys,json
|
|
||||||
assets = json.load(sys.stdin)
|
|
||||||
for a in assets:
|
|
||||||
if a['name'] == '${ASSET_FILE}':
|
|
||||||
print(a['id']); break
|
|
||||||
" 2>/dev/null || true)
|
|
||||||
if [ -n "$ASSET_ID" ]; then
|
|
||||||
curl -sf -X DELETE -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets/${ASSET_ID}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Upload both formats
|
|
||||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary @"/tmp/${PACKAGE_NAME}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${PACKAGE_NAME}" > /dev/null 2>&1 || true
|
|
||||||
|
|
||||||
curl -sf -X POST -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary @"/tmp/${TAR_NAME}" \
|
|
||||||
"${API_BASE}/releases/${RELEASE_ID}/assets?name=${TAR_NAME}" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Packages: ${PACKAGE_NAME} + ${TAR_NAME} (SHA: ${SHA256})" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
SHA256=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# -- Build the new entry (canonical format matching release.yml) --
|
|
||||||
NEW_ENTRY=""
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <update>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <name>${EXT_NAME}</name>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <description>${EXT_NAME} ${STABILITY} build.</description>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <element>${EXT_ELEMENT}</element>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <type>${EXT_TYPE}</type>\n"
|
|
||||||
[ -n "$CLIENT_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${CLIENT_TAG}\n"
|
|
||||||
[ -n "$FOLDER_TAG" ] && NEW_ENTRY="${NEW_ENTRY} ${FOLDER_TAG}\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <version>${VERSION}</version>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <creationDate>$(date +%Y-%m-%d)</creationDate>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <infourl title='${EXT_NAME}'>https://git.mokoconsulting.tech/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${RELEASE_TAG}</infourl>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <downloads>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <downloadurl type='full' format='zip'>${DOWNLOAD_URL}</downloadurl>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} </downloads>\n"
|
|
||||||
[ -n "$SHA256" ] && NEW_ENTRY="${NEW_ENTRY} <sha256>${SHA256}</sha256>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <tags><tag>${STABILITY}</tag></tags>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <maintainer>Moko Consulting</maintainer>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <maintainerurl>https://mokoconsulting.tech</maintainerurl>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} <targetplatform name='joomla' version='(5|6).*'/>\n"
|
|
||||||
[ -n "$PHP_MINIMUM" ] && NEW_ENTRY="${NEW_ENTRY} <php_minimum>${PHP_MINIMUM}</php_minimum>\n"
|
|
||||||
NEW_ENTRY="${NEW_ENTRY} </update>"
|
|
||||||
|
|
||||||
# -- Write new entry to temp file --------------------------------
|
|
||||||
printf '%b' "$NEW_ENTRY" > /tmp/new_entry.xml
|
|
||||||
|
|
||||||
# -- Merge into updates.xml ----------------------------------------
|
|
||||||
# Cascade: stable→all | rc→rc+lower | beta→beta+lower | alpha→alpha+dev | dev→dev
|
|
||||||
CASCADE_MAP="stable:development,alpha,beta,rc,stable rc:development,alpha,beta,rc beta:development,alpha,beta alpha:development,alpha development:development"
|
|
||||||
TARGETS=""
|
|
||||||
for entry in $CASCADE_MAP; do
|
|
||||||
key="${entry%%:*}"
|
|
||||||
vals="${entry#*:}"
|
|
||||||
if [ "$key" = "${STABILITY}" ]; then
|
|
||||||
TARGETS="$vals"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[ -z "$TARGETS" ] && TARGETS="${STABILITY}"
|
|
||||||
|
|
||||||
echo "Cascade: ${STABILITY} → ${TARGETS}"
|
|
||||||
|
|
||||||
# Create updates.xml if missing
|
|
||||||
if [ ! -f "updates.xml" ]; then
|
|
||||||
printf '%s\n' "<?xml version='1.0' encoding='UTF-8'?>" > updates.xml
|
|
||||||
printf '%s\n' "<!-- Copyright (C) $(date +%Y) Moko Consulting -->" >> updates.xml
|
|
||||||
printf '%s\n' "<updates>" >> updates.xml
|
|
||||||
printf '%s\n' "</updates>" >> updates.xml
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update existing blocks or create missing ones
|
|
||||||
export PY_TARGETS="$TARGETS" PY_VERSION="$VERSION" PY_DATE="$(date +%Y-%m-%d)"
|
|
||||||
python3 << 'PYEOF'
|
|
||||||
import re, os
|
|
||||||
|
|
||||||
targets = os.environ["PY_TARGETS"].split(",")
|
|
||||||
version = os.environ["PY_VERSION"]
|
|
||||||
date = os.environ["PY_DATE"]
|
|
||||||
|
|
||||||
with open("updates.xml") as f:
|
|
||||||
content = f.read()
|
|
||||||
with open("/tmp/new_entry.xml") as f:
|
|
||||||
new_entry_template = f.read()
|
|
||||||
|
|
||||||
for tag in targets:
|
|
||||||
tag = tag.strip()
|
|
||||||
# Build entry with this tag's name
|
|
||||||
new_entry = re.sub(r"<tag>[^<]*</tag>", f"<tag>{tag}</tag>", new_entry_template)
|
|
||||||
|
|
||||||
# Try to find existing block (handles both single-line and multi-line <tags>)
|
|
||||||
block_pattern = r"(<update>(?:(?!</update>).)*?<tag>" + re.escape(tag) + r"</tag>.*?</update>)"
|
|
||||||
match = re.search(block_pattern, content, re.DOTALL)
|
|
||||||
|
|
||||||
if match:
|
|
||||||
# Update in place — replace entire block
|
|
||||||
content = content.replace(match.group(1), new_entry.strip())
|
|
||||||
print(f" UPDATED: <tag>{tag}</tag> → {version}")
|
|
||||||
else:
|
|
||||||
# Create — insert before </updates>
|
|
||||||
content = content.replace("</updates>", "\n" + new_entry.strip() + "\n\n</updates>")
|
|
||||||
print(f" CREATED: <tag>{tag}</tag> → {version}")
|
|
||||||
|
|
||||||
# Clean up excessive blank lines
|
|
||||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
|
||||||
|
|
||||||
with open("updates.xml", "w") as f:
|
|
||||||
f.write(content)
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
# Commit
|
|
||||||
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
|
|
||||||
git config --local user.name "gitea-actions[bot]"
|
|
||||||
git add updates.xml
|
|
||||||
git diff --cached --quiet || {
|
|
||||||
git commit -m "chore: update updates.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \
|
|
||||||
--author="gitea-actions[bot] <gitea-actions[bot]@mokoconsulting.tech>"
|
|
||||||
git push
|
|
||||||
}
|
|
||||||
|
|
||||||
# -- Sync updates.xml to main (for non-main branches) ----------------------
|
|
||||||
- name: Sync updates.xml to main
|
|
||||||
if: github.ref_name != 'main'
|
|
||||||
run: |
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
GA_TOKEN="${{ secrets.GA_TOKEN }}"
|
|
||||||
|
|
||||||
FILE_SHA=$(curl -sf -H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
"${API_BASE}/contents/updates.xml?ref=main" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sha',''))" 2>/dev/null || true)
|
|
||||||
|
|
||||||
if [ -n "$FILE_SHA" ] && [ -f "updates.xml" ]; then
|
|
||||||
CONTENT=$(base64 -w0 updates.xml)
|
|
||||||
curl -sf -X PUT -H "Authorization: token ${GA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API_BASE}/contents/updates.xml" \
|
|
||||||
-d "$(python3 -c "import json; print(json.dumps({
|
|
||||||
'content': '${CONTENT}',
|
|
||||||
'sha': '${FILE_SHA}',
|
|
||||||
'message': 'chore: sync updates.xml from ${STABILITY} [skip ci]',
|
|
||||||
'branch': 'main'
|
|
||||||
}))")" > /dev/null 2>&1 \
|
|
||||||
&& echo "updates.xml synced to main (${STABILITY})" >> $GITHUB_STEP_SUMMARY \
|
|
||||||
|| echo "WARNING: failed to sync updates.xml to main" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "WARNING: could not get updates.xml SHA from main" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: SFTP deploy to dev server
|
|
||||||
if: contains(github.ref, 'dev/') || github.ref == 'refs/heads/dev'
|
|
||||||
env:
|
|
||||||
DEV_HOST: ${{ vars.DEV_FTP_HOST }}
|
|
||||||
DEV_PATH: ${{ vars.DEV_FTP_PATH }}
|
|
||||||
DEV_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
|
||||||
DEV_USER: ${{ vars.DEV_FTP_USERNAME }}
|
|
||||||
DEV_PORT: ${{ vars.DEV_FTP_PORT }}
|
|
||||||
DEV_KEY: ${{ secrets.DEV_FTP_KEY }}
|
|
||||||
DEV_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
# -- Permission check: admin or maintain role required --------
|
|
||||||
ACTOR="${{ github.actor }}"
|
|
||||||
REPO="${{ github.repository }}"
|
|
||||||
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
|
|
||||||
|
|
||||||
PERMISSION=$(curl -sf -H "Authorization: token ${{ secrets.GA_TOKEN }}" \
|
|
||||||
"${API_BASE}/collaborators/${ACTOR}/permission" 2>/dev/null | \
|
|
||||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('permission','read'))" 2>/dev/null || echo "read")
|
|
||||||
case "$PERMISSION" in
|
|
||||||
admin|maintain|write) ;;
|
|
||||||
*)
|
|
||||||
echo "Deploy denied: ${ACTOR} has '${PERMISSION}' — requires admin, maintain, or write"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[ -z "$DEV_HOST" ] || [ -z "$DEV_PATH" ] && { echo "DEV FTP not configured — skipping SFTP"; exit 0; }
|
|
||||||
|
|
||||||
SOURCE_DIR="src"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
||||||
[ ! -d "$SOURCE_DIR" ] && exit 0
|
|
||||||
|
|
||||||
PORT="${DEV_PORT:-22}"
|
|
||||||
REMOTE="${DEV_PATH%/}"
|
|
||||||
[ -n "$DEV_SUFFIX" ] && REMOTE="${REMOTE}/${DEV_SUFFIX#/}"
|
|
||||||
|
|
||||||
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
|
|
||||||
"$DEV_HOST" "$PORT" "$DEV_USER" "$REMOTE" > /tmp/sftp-config.json
|
|
||||||
if [ -n "$DEV_KEY" ]; then
|
|
||||||
echo "$DEV_KEY" > /tmp/deploy_key && chmod 600 /tmp/deploy_key
|
|
||||||
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
|
|
||||||
else
|
|
||||||
printf ',"password":"%s"}' "$DEV_PASS" >> /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
|
|
||||||
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-joomla.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
|
||||||
elif [ -f "/tmp/mokostandards-api/deploy/deploy-sftp.php" ]; then
|
|
||||||
php /tmp/mokostandards-api/deploy/deploy-sftp.php --path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
|
||||||
echo "SFTP deploy to dev complete" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY
|
|
||||||
+6
-59
@@ -11,62 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Removed deploy-manual.yml workflow — switching to Joomla update server method for extension distribution
|
- Removed deploy-manual.yml workflow — switching to Joomla update server method for extension distribution
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Package** with component, 2 modules, and web services API plugin
|
- Initial package scaffold with component, map module, and search module
|
||||||
- **Component (com_mokojoomstorelocator)**
|
- Database schema for locations table with coordinates
|
||||||
- Admin location CRUD with tabbed edit form (details, address, coordinates, contact, media)
|
- Admin MVC for location CRUD
|
||||||
- Leaflet coordinate picker — click map to set lat/lng on admin form
|
- Frontend location listing view with Schema.org markup
|
||||||
- Locations list with publish/unpublish/delete, search, pagination
|
- Map module with Leaflet/Google Maps provider support
|
||||||
- Categories admin with parent/child hierarchy, color, and custom marker icon
|
- Search module with city and radius filter options
|
||||||
- Multi-category support — locations assigned to multiple categories via junction table
|
|
||||||
- CSV import with 3-step wizard (upload, column mapping, preview/validate)
|
|
||||||
- CSV export with filter support and UTF-8 BOM for Excel
|
|
||||||
- Sample data injection (8 Tennessee locations with real coordinates)
|
|
||||||
- Geocoding service — Nominatim (free default) and Google Geocoding API
|
|
||||||
- Auto-geocode on save when address present but coordinates missing
|
|
||||||
- Video URL field with YouTube/Vimeo embed support
|
|
||||||
- Multiple images field (gallery) per location
|
|
||||||
- Contact form per location with email delivery and captcha
|
|
||||||
- Custom fields integration via Joomla com_fields (location + category contexts)
|
|
||||||
- Component config: geocoding provider, Google API key, auto-geocode toggle
|
|
||||||
- access.xml with component and category-level ACL permissions
|
|
||||||
- SQL update schema with versioned migration files
|
|
||||||
- Filter forms: filter_locations.xml, filter_categories.xml
|
|
||||||
- populateState for persistent admin list filters
|
|
||||||
- joomla.asset.json for Web Asset Manager (Leaflet, MarkerCluster, CSS)
|
|
||||||
- **Frontend (site views)**
|
|
||||||
- Locations list with linked titles, distance display, Get Directions buttons
|
|
||||||
- Single location detail page with embedded map, Schema.org JSON-LD
|
|
||||||
- Category view — locations filtered by category with color swatch
|
|
||||||
- SEF URL router (locations, location by alias, category by alias)
|
|
||||||
- Contact form embedded on location detail page
|
|
||||||
- Photo gallery and responsive video embed on detail page
|
|
||||||
- Print button with print stylesheet and static map image
|
|
||||||
- Category tags with color badges and links
|
|
||||||
- Responsive CSS with mobile-first grid, click-to-call phone
|
|
||||||
- **Map Module (mod_mokojoomstorelocator_map)**
|
|
||||||
- Leaflet.js with OpenStreetMap tiles (no API key required)
|
|
||||||
- Google Maps provider (optional, with API key)
|
|
||||||
- MarkerCluster plugin for both providers
|
|
||||||
- Category-colored SVG markers with custom icon override
|
|
||||||
- Category legend below map
|
|
||||||
- DOM-based popup content (XSS-safe)
|
|
||||||
- Auto-fit bounds to show all markers
|
|
||||||
- Get Directions link in popups
|
|
||||||
- **Search Module (mod_mokojoomstorelocator_search)**
|
|
||||||
- Text search (title, address, city, postcode)
|
|
||||||
- City dropdown filter from distinct values
|
|
||||||
- Radius filter with configurable options (miles/km)
|
|
||||||
- "Use My Location" geolocation button with permission handling
|
|
||||||
- Haversine formula for distance calculation in SQL
|
|
||||||
- **Web Services API (plg_webservices_mokojoomstorelocator)**
|
|
||||||
- CRUD routes for /v1/storelocator/locations
|
|
||||||
- CRUD routes for /v1/storelocator/categories
|
|
||||||
- Custom search route: /v1/storelocator/search
|
|
||||||
- JsonapiView for Locations and Categories
|
|
||||||
- **Package installer**
|
|
||||||
- Auto-enables modules and API plugin on install
|
|
||||||
- PHP 8.1+ and Joomla 4.4+ version checks
|
|
||||||
- en-GB and en-US language files for all extensions
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Hardcode name and description in all XML manifests (language variables don't resolve during install)
|
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# FILE INFORMATION
|
||||||
|
# DEFGROUP: Automation.CI
|
||||||
|
# INGROUP: moko-platform.Automation
|
||||||
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||||
|
# PATH: /automation/ci-issue-reporter.sh
|
||||||
|
# VERSION: 09.23.00
|
||||||
|
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
|
||||||
|
# Deduplicates by searching open issues with the "ci-auto" label
|
||||||
|
# whose title matches the gate. If a matching issue exists, a comment
|
||||||
|
# is appended instead of opening a duplicate.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Defaults ────────────────────────────────────────────────────────────────
|
||||||
|
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
|
||||||
|
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||||
|
REPO="${GITHUB_REPOSITORY:-}"
|
||||||
|
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
|
||||||
|
LABEL_NAME="ci-auto"
|
||||||
|
LABEL_COLOR="#e11d48"
|
||||||
|
|
||||||
|
GATE=""
|
||||||
|
DETAILS=""
|
||||||
|
SEVERITY="error"
|
||||||
|
WORKFLOW=""
|
||||||
|
|
||||||
|
# ── Parse arguments ─────────────────────────────────────────────────────────
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--gate CI gate name (e.g. "Code Quality", "Self-Health")
|
||||||
|
--details Human-readable failure description
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
--severity "error" (default) or "warning"
|
||||||
|
--workflow Workflow name for the issue title
|
||||||
|
--repo owner/repo (default: \$GITHUB_REPOSITORY)
|
||||||
|
--run-url URL to the CI run (auto-detected from env)
|
||||||
|
--token Gitea API token (default: \$GITEA_TOKEN)
|
||||||
|
--url Gitea base URL (default: \$GITEA_URL)
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--gate) GATE="$2"; shift 2 ;;
|
||||||
|
--details) DETAILS="$2"; shift 2 ;;
|
||||||
|
--severity) SEVERITY="$2"; shift 2 ;;
|
||||||
|
--workflow) WORKFLOW="$2"; shift 2 ;;
|
||||||
|
--repo) REPO="$2"; shift 2 ;;
|
||||||
|
--run-url) RUN_URL="$2"; shift 2 ;;
|
||||||
|
--token) GITEA_TOKEN="$2"; shift 2 ;;
|
||||||
|
--url) GITEA_URL="$2"; shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) echo "Unknown option: $1"; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
|
||||||
|
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
|
||||||
|
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
|
||||||
|
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
|
||||||
|
|
||||||
|
API="${GITEA_URL}/api/v1/repos/${REPO}"
|
||||||
|
|
||||||
|
# ── Build title ─────────────────────────────────────────────────────────────
|
||||||
|
if [[ -n "$WORKFLOW" ]]; then
|
||||||
|
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
|
||||||
|
else
|
||||||
|
TITLE="[CI] ${GATE} failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Ensure label exists ─────────────────────────────────────────────────────
|
||||||
|
ensure_label() {
|
||||||
|
local exists
|
||||||
|
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/labels" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [[ "$exists" == "200" ]]; then
|
||||||
|
# Check if label already exists
|
||||||
|
local found
|
||||||
|
found=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/labels" 2>/dev/null \
|
||||||
|
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
|
||||||
|
|
||||||
|
if [[ -z "$found" ]]; then
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/labels" \
|
||||||
|
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
|
||||||
|
> /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Search for existing open issue ──────────────────────────────────────────
|
||||||
|
find_existing_issue() {
|
||||||
|
# URL-encode the gate name for the query
|
||||||
|
local query
|
||||||
|
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
|
||||||
|
2>/dev/null || echo "[]")
|
||||||
|
|
||||||
|
# Extract the first matching issue number
|
||||||
|
echo "$response" \
|
||||||
|
| grep -oP '"number":\s*\K[0-9]+' \
|
||||||
|
| head -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Build issue body ────────────────────────────────────────────────────────
|
||||||
|
build_body() {
|
||||||
|
local severity_badge
|
||||||
|
if [[ "$SEVERITY" == "error" ]]; then
|
||||||
|
severity_badge="**Severity:** Error"
|
||||||
|
else
|
||||||
|
severity_badge="**Severity:** Warning"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<BODY
|
||||||
|
## CI Gate Failure: ${GATE}
|
||||||
|
|
||||||
|
${severity_badge}
|
||||||
|
**Workflow:** ${WORKFLOW:-unknown}
|
||||||
|
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||||
|
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||||
|
**Run:** [View CI run](${RUN_URL})
|
||||||
|
|
||||||
|
### Details
|
||||||
|
|
||||||
|
${DETAILS}
|
||||||
|
|
||||||
|
### Resolution
|
||||||
|
|
||||||
|
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
|
||||||
|
BODY
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Build comment body (for existing issues) ────────────────────────────────
|
||||||
|
build_comment() {
|
||||||
|
cat <<COMMENT
|
||||||
|
### CI failure recurrence
|
||||||
|
|
||||||
|
**Branch:** ${GITHUB_REF_NAME:-unknown}
|
||||||
|
**Commit:** \`${GITHUB_SHA:0:8}\`
|
||||||
|
**Run:** [View CI run](${RUN_URL})
|
||||||
|
|
||||||
|
${DETAILS}
|
||||||
|
COMMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
ensure_label
|
||||||
|
|
||||||
|
EXISTING=$(find_existing_issue)
|
||||||
|
|
||||||
|
if [[ -n "$EXISTING" ]]; then
|
||||||
|
# Append comment to existing issue
|
||||||
|
COMMENT_BODY=$(build_comment)
|
||||||
|
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
|
||||||
|
|
||||||
|
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/issues/${EXISTING}/comments" \
|
||||||
|
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [[ "$HTTP" == "201" ]]; then
|
||||||
|
echo "Commented on existing issue #${EXISTING}"
|
||||||
|
else
|
||||||
|
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Create new issue
|
||||||
|
ISSUE_BODY=$(build_body)
|
||||||
|
ISSUE_JSON=$(python3 -c "
|
||||||
|
import sys, json
|
||||||
|
body = sys.stdin.read()
|
||||||
|
print(json.dumps({
|
||||||
|
'title': sys.argv[1],
|
||||||
|
'body': body,
|
||||||
|
'labels': []
|
||||||
|
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
|
||||||
|
|
||||||
|
# Create the issue
|
||||||
|
RESPONSE=$(curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/issues" \
|
||||||
|
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
|
||||||
|
|
||||||
|
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
|
||||||
|
|
||||||
|
if [[ -n "$ISSUE_NUM" ]]; then
|
||||||
|
# Apply label (separate call — more reliable across Gitea versions)
|
||||||
|
LABEL_ID=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/labels" 2>/dev/null \
|
||||||
|
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
|
||||||
|
| head -1 || true)
|
||||||
|
|
||||||
|
if [[ -n "$LABEL_ID" ]]; then
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${API}/issues/${ISSUE_NUM}/labels" \
|
||||||
|
-d "{\"labels\":[${LABEL_ID}]}" \
|
||||||
|
> /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
|
||||||
|
else
|
||||||
|
echo "WARNING: Failed to create issue"
|
||||||
|
echo "Response: ${RESPONSE}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<access component="com_mokojoomstorelocator">
|
|
||||||
<section name="component">
|
|
||||||
<action name="core.admin" title="JACTION_ADMIN" />
|
|
||||||
<action name="core.manage" title="JACTION_MANAGE" />
|
|
||||||
<action name="core.create" title="JACTION_CREATE" />
|
|
||||||
<action name="core.edit" title="JACTION_EDIT" />
|
|
||||||
<action name="core.edit.state" title="JACTION_EDITSTATE" />
|
|
||||||
<action name="core.delete" title="JACTION_DELETE" />
|
|
||||||
</section>
|
|
||||||
<section name="category">
|
|
||||||
<action name="core.create" title="JACTION_CREATE" />
|
|
||||||
<action name="core.edit" title="JACTION_EDIT" />
|
|
||||||
<action name="core.edit.state" title="JACTION_EDITSTATE" />
|
|
||||||
<action name="core.delete" title="JACTION_DELETE" />
|
|
||||||
</section>
|
|
||||||
</access>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<form>
|
|
||||||
<fieldset name="details">
|
|
||||||
<field name="id" type="hidden" />
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="title"
|
|
||||||
type="text"
|
|
||||||
label="JGLOBAL_TITLE"
|
|
||||||
required="true"
|
|
||||||
size="40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="alias"
|
|
||||||
type="text"
|
|
||||||
label="JFIELD_ALIAS_LABEL"
|
|
||||||
size="40"
|
|
||||||
hint="JFIELD_ALIAS_PLACEHOLDER"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="parent_id"
|
|
||||||
type="sql"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_PARENT_CATEGORY"
|
|
||||||
query="SELECT id, title FROM #__mokojoomstorelocator_categories WHERE published = 1 ORDER BY title"
|
|
||||||
key_field="id"
|
|
||||||
value_field="title"
|
|
||||||
header="COM_MOKOJOOMSTORELOCATOR_NO_PARENT"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="description"
|
|
||||||
type="editor"
|
|
||||||
label="JGLOBAL_DESCRIPTION"
|
|
||||||
filter="safehtml"
|
|
||||||
buttons="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="published"
|
|
||||||
type="list"
|
|
||||||
label="JSTATUS"
|
|
||||||
default="1"
|
|
||||||
>
|
|
||||||
<option value="1">JPUBLISHED</option>
|
|
||||||
<option value="0">JUNPUBLISHED</option>
|
|
||||||
</field>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset name="appearance" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE">
|
|
||||||
<field
|
|
||||||
name="color"
|
|
||||||
type="color"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR"
|
|
||||||
description="COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR_DESC"
|
|
||||||
default="#3b82f6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="marker_icon"
|
|
||||||
type="media"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON"
|
|
||||||
description="COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON_DESC"
|
|
||||||
directory="storelocator/markers"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="image"
|
|
||||||
type="media"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORY_IMAGE"
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<form>
|
|
||||||
<fields name="filter">
|
|
||||||
<field name="search" type="text" label="JSEARCH_FILTER" hint="Search by category name" />
|
|
||||||
<field name="published" type="status" label="JOPTION_SELECT_PUBLISHED" onchange="this.form.submit()">
|
|
||||||
<option value="">JOPTION_SELECT_PUBLISHED</option>
|
|
||||||
</field>
|
|
||||||
</fields>
|
|
||||||
<fields name="list">
|
|
||||||
<field name="fullordering" type="list" label="JGLOBAL_SORT_BY" default="a.ordering ASC" onchange="this.form.submit()">
|
|
||||||
<option value="a.ordering ASC">JGRID_HEADING_ORDERING</option>
|
|
||||||
<option value="a.title ASC">JGLOBAL_TITLE</option>
|
|
||||||
</field>
|
|
||||||
</fields>
|
|
||||||
</form>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<form>
|
|
||||||
<fields name="filter">
|
|
||||||
<field name="search" type="text" label="JSEARCH_FILTER" hint="Search by title, address, city, postcode" />
|
|
||||||
<field name="published" type="status" label="JOPTION_SELECT_PUBLISHED" onchange="this.form.submit()">
|
|
||||||
<option value="">JOPTION_SELECT_PUBLISHED</option>
|
|
||||||
</field>
|
|
||||||
<field name="category_id" type="sql" label="Category" onchange="this.form.submit()"
|
|
||||||
query="SELECT id, title FROM #__mokojoomstorelocator_categories WHERE published=1 ORDER BY title"
|
|
||||||
key_field="id" value_field="title">
|
|
||||||
<option value="">- All Categories -</option>
|
|
||||||
</field>
|
|
||||||
<field name="city" type="sql" label="City" onchange="this.form.submit()"
|
|
||||||
query="SELECT DISTINCT city FROM #__mokojoomstorelocator_locations WHERE city != '' ORDER BY city"
|
|
||||||
key_field="city" value_field="city">
|
|
||||||
<option value="">- All Cities -</option>
|
|
||||||
</field>
|
|
||||||
<field name="state" type="sql" label="State" onchange="this.form.submit()"
|
|
||||||
query="SELECT DISTINCT state FROM #__mokojoomstorelocator_locations WHERE state != '' ORDER BY state"
|
|
||||||
key_field="state" value_field="state">
|
|
||||||
<option value="">- All States -</option>
|
|
||||||
</field>
|
|
||||||
</fields>
|
|
||||||
<fields name="list">
|
|
||||||
<field name="fullordering" type="list" label="JGLOBAL_SORT_BY" default="a.ordering ASC" onchange="this.form.submit()">
|
|
||||||
<option value="a.ordering ASC">JGRID_HEADING_ORDERING</option>
|
|
||||||
<option value="a.title ASC">JGLOBAL_TITLE</option>
|
|
||||||
<option value="a.city ASC">City</option>
|
|
||||||
<option value="a.state ASC">State</option>
|
|
||||||
<option value="a.id DESC">Newest</option>
|
|
||||||
</field>
|
|
||||||
</fields>
|
|
||||||
</form>
|
|
||||||
@@ -41,15 +41,6 @@
|
|||||||
<option value="0">JUNPUBLISHED</option>
|
<option value="0">JUNPUBLISHED</option>
|
||||||
<option value="-2">JTRASHED</option>
|
<option value="-2">JTRASHED</option>
|
||||||
</field>
|
</field>
|
||||||
|
|
||||||
<field
|
|
||||||
name="category_ids"
|
|
||||||
type="LocationCategories"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES"
|
|
||||||
description="COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES_DESC"
|
|
||||||
multiple="true"
|
|
||||||
class="form-select"
|
|
||||||
/>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="address" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS">
|
<fieldset name="address" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS">
|
||||||
@@ -139,30 +130,11 @@
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="media" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA">
|
<fieldset name="image" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE">
|
||||||
<field
|
<field
|
||||||
name="image"
|
name="image"
|
||||||
type="media"
|
type="media"
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE"
|
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE"
|
||||||
description="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="images"
|
|
||||||
type="textarea"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES"
|
|
||||||
description="COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC"
|
|
||||||
rows="4"
|
|
||||||
hint="images/stores/photo1.jpg images/stores/photo2.jpg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="video_url"
|
|
||||||
type="url"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL"
|
|
||||||
description="COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC"
|
|
||||||
size="60"
|
|
||||||
hint="https://www.youtube.com/watch?v=..."
|
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
-57
@@ -28,60 +28,3 @@ COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
|
|||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
|
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
|
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image"
|
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image"
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING="Geocoding"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER="Geocoding Provider"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY="Google API Key"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC="Required for Google Geocoding and Google Maps. Get one at console.cloud.google.com"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE="Auto-Geocode on Save"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC="Automatically convert addresses to coordinates when saving a location."
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT="Import"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE="Import Locations from CSV"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Upload a CSV file with location data. First row must be column headers."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT="Invalid file format. Only CSV files are accepted."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE="Geocode missing coordinates"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE_DESC="Auto-fill latitude/longitude for locations without coordinates. Uses your configured geocoding provider."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING="Update existing locations"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING_DESC="Match by title and update existing records instead of creating duplicates."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT="Import Locations"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT="CSV Format"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT_DESC="The CSV file must have a header row. Supported columns:"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE="Download CSV Template"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT="Import complete: %d imported, %d updated, %d skipped."
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NEW="New Category"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORY_EDIT="Edit Category"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_PARENT="Parent"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_NO_PARENT="— No Parent —"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_PARENT_CATEGORY="Parent Category"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES="Categories"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES_DESC="Assign this location to one or more categories."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE="Appearance & Marker"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR="Color"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR_DESC="Used for map markers when no custom icon is set."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON="Custom Marker Icon"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON_DESC="Upload an SVG or PNG image (recommended 32x32px). Overrides the color-based marker."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORY_IMAGE="Category Image"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDS_LOCATION="Location Custom Fields"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDS_CATEGORY="Category Custom Fields"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_EXPORT="Export"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV="Export to CSV"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA="Sample Data"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT="Install Sample Data"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT_CONFIRM="This will add 8 sample store locations to your database. Continue?"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED="%d sample locations installed successfully."
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA="Media"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC="Primary location image."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES="Additional Photos"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC="One image path per line. These display as a photo gallery on the location detail page."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL="Video URL"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC="YouTube or Vimeo URL. Embeds on the location detail page."
|
|
||||||
|
|||||||
-87
@@ -1,87 +0,0 @@
|
|||||||
; MokoJoomStoreLocator - Admin language strings
|
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
; License: GNU General Public License version 3 or later; see LICENSE
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR="Store Locator"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_NEW="New Location"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_EDIT="Edit Location"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_TABLE_CAPTION="Store Location List"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CITY="City"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_STATE="State"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES="Coordinates"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE="Image"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_ADDRESS="Street Address"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_CITY="City"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_STATE="State / Province"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_POSTCODE="Postal Code"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_COUNTRY="Country"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_LATITUDE="Latitude"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_LONGITUDE="Longitude"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE="Location Image"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING="Geocoding"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER="Geocoding Provider"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY="Google API Key"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC="Required for Google Geocoding and Google Maps. Get one at console.cloud.google.com"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE="Auto-Geocode on Save"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC="Automatically convert addresses to coordinates when saving a location."
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT="Import"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE="Import Locations from CSV"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE="CSV File"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_DESC="Upload a CSV file with location data. First row must be column headers."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE="No file was uploaded."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT="Invalid file format. Only CSV files are accepted."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE="Geocode missing coordinates"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE_DESC="Auto-fill latitude/longitude for locations without coordinates. Uses your configured geocoding provider."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING="Update existing locations"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING_DESC="Match by title and update existing records instead of creating duplicates."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT="Import Locations"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT="CSV Format"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT_DESC="The CSV file must have a header row. Supported columns:"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE="Download CSV Template"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT="Import complete: %d imported, %d updated, %d skipped."
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORY_NEW="New Category"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORY_EDIT="Edit Category"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_PARENT="Parent"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_NO_PARENT="— No Parent —"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_PARENT_CATEGORY="Parent Category"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES="Categories"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORIES_DESC="Assign this location to one or more categories."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_APPEARANCE="Appearance & Marker"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR="Color"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR_DESC="Used for map markers when no custom icon is set."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON="Custom Marker Icon"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_MARKER_ICON_DESC="Upload an SVG or PNG image (recommended 32x32px). Overrides the color-based marker."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_CATEGORY_IMAGE="Category Image"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDS_LOCATION="Location Custom Fields"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDS_CATEGORY="Category Custom Fields"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_EXPORT="Export"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV="Export to CSV"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA="Sample Data"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT="Install Sample Data"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT_CONFIRM="This will add 8 sample store locations to your database. Continue?"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED="%d sample locations installed successfully."
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_MEDIA="Media"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGE_DESC="Primary location image."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES="Additional Photos"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_IMAGES_DESC="One image path per line. These display as a photo gallery on the location detail page."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL="Video URL"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_VIDEO_URL_DESC="YouTube or Vimeo URL. Embeds on the location detail page."
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
; MokoJoomStoreLocator - System language strings
|
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
; License: GNU General Public License version 3 or later; see LICENSE
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR="Store Locator"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_DESC="A store locator component for managing and displaying location listings."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Locations"
|
|
||||||
@@ -2,10 +2,9 @@
|
|||||||
-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
-- SPDX-License-Identifier: GPL-3.0-or-later
|
-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
--
|
--
|
||||||
-- MokoJoomStoreLocator - Database schema
|
-- MokoJoomStoreLocator - Store locations table
|
||||||
-- =========================================================================
|
-- =========================================================================
|
||||||
|
|
||||||
-- Store locations
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` (
|
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` (
|
||||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
`title` varchar(255) NOT NULL DEFAULT '',
|
`title` varchar(255) NOT NULL DEFAULT '',
|
||||||
@@ -23,8 +22,6 @@ CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` (
|
|||||||
`website` varchar(255) NOT NULL DEFAULT '',
|
`website` varchar(255) NOT NULL DEFAULT '',
|
||||||
`hours` text NOT NULL,
|
`hours` text NOT NULL,
|
||||||
`image` varchar(255) NOT NULL DEFAULT '',
|
`image` varchar(255) NOT NULL DEFAULT '',
|
||||||
`images` text NOT NULL,
|
|
||||||
`video_url` varchar(500) NOT NULL DEFAULT '',
|
|
||||||
`published` tinyint(4) NOT NULL DEFAULT 0,
|
`published` tinyint(4) NOT NULL DEFAULT 0,
|
||||||
`ordering` int(11) NOT NULL DEFAULT 0,
|
`ordering` int(11) NOT NULL DEFAULT 0,
|
||||||
`catid` int(11) NOT NULL DEFAULT 0,
|
`catid` int(11) NOT NULL DEFAULT 0,
|
||||||
@@ -41,32 +38,3 @@ CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_locations` (
|
|||||||
KEY `idx_alias` (`alias`(191)),
|
KEY `idx_alias` (`alias`(191)),
|
||||||
KEY `idx_coordinates` (`latitude`, `longitude`)
|
KEY `idx_coordinates` (`latitude`, `longitude`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Location categories with hierarchy, color, and custom marker support
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_categories` (
|
|
||||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
||||||
`parent_id` int(11) NOT NULL DEFAULT 0,
|
|
||||||
`title` varchar(255) NOT NULL DEFAULT '',
|
|
||||||
`alias` varchar(400) NOT NULL DEFAULT '',
|
|
||||||
`description` text NOT NULL,
|
|
||||||
`color` varchar(7) NOT NULL DEFAULT '#3b82f6',
|
|
||||||
`marker_icon` varchar(255) NOT NULL DEFAULT '',
|
|
||||||
`image` varchar(255) NOT NULL DEFAULT '',
|
|
||||||
`published` tinyint(4) NOT NULL DEFAULT 0,
|
|
||||||
`ordering` int(11) NOT NULL DEFAULT 0,
|
|
||||||
`params` text NOT NULL,
|
|
||||||
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
`modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_parent` (`parent_id`),
|
|
||||||
KEY `idx_published` (`published`),
|
|
||||||
KEY `idx_alias` (`alias`(191))
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- Many-to-many: locations <-> categories
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_location_categories` (
|
|
||||||
`location_id` int(11) NOT NULL,
|
|
||||||
`category_id` int(11) NOT NULL,
|
|
||||||
PRIMARY KEY (`location_id`, `category_id`),
|
|
||||||
KEY `idx_category` (`category_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|||||||
@@ -3,6 +3,4 @@
|
|||||||
-- SPDX-License-Identifier: GPL-3.0-or-later
|
-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
-- =========================================================================
|
-- =========================================================================
|
||||||
|
|
||||||
DROP TABLE IF EXISTS `#__mokojoomstorelocator_location_categories`;
|
|
||||||
DROP TABLE IF EXISTS `#__mokojoomstorelocator_categories`;
|
|
||||||
DROP TABLE IF EXISTS `#__mokojoomstorelocator_locations`;
|
DROP TABLE IF EXISTS `#__mokojoomstorelocator_locations`;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
-- v1.0.0 initial schema marker
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-- v1.0.1: Add images and video_url columns, categories table, junction table
|
|
||||||
ALTER TABLE `#__mokojoomstorelocator_locations` ADD COLUMN IF NOT EXISTS `images` text NOT NULL AFTER `image`;
|
|
||||||
ALTER TABLE `#__mokojoomstorelocator_locations` ADD COLUMN IF NOT EXISTS `video_url` varchar(500) NOT NULL DEFAULT '' AFTER `images`;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_categories` (
|
|
||||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
|
||||||
`parent_id` int(11) NOT NULL DEFAULT 0,
|
|
||||||
`title` varchar(255) NOT NULL DEFAULT '',
|
|
||||||
`alias` varchar(400) NOT NULL DEFAULT '',
|
|
||||||
`description` text NOT NULL,
|
|
||||||
`color` varchar(7) NOT NULL DEFAULT '#3b82f6',
|
|
||||||
`marker_icon` varchar(255) NOT NULL DEFAULT '',
|
|
||||||
`image` varchar(255) NOT NULL DEFAULT '',
|
|
||||||
`published` tinyint(4) NOT NULL DEFAULT 0,
|
|
||||||
`ordering` int(11) NOT NULL DEFAULT 0,
|
|
||||||
`params` text NOT NULL,
|
|
||||||
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
`modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_parent` (`parent_id`),
|
|
||||||
KEY `idx_published` (`published`),
|
|
||||||
KEY `idx_alias` (`alias`(191))
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `#__mokojoomstorelocator_location_categories` (
|
|
||||||
`location_id` int(11) NOT NULL,
|
|
||||||
`category_id` int(11) NOT NULL,
|
|
||||||
PRIMARY KEY (`location_id`, `category_id`),
|
|
||||||
KEY `idx_category` (`category_id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Controller\AdminController;
|
|
||||||
|
|
||||||
class CategoriesController extends AdminController
|
|
||||||
{
|
|
||||||
public function getModel($name = 'Category', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
|
||||||
{
|
|
||||||
return parent::getModel($name, $prefix, $config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Controller\FormController;
|
|
||||||
|
|
||||||
class CategoryController extends FormController
|
|
||||||
{
|
|
||||||
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_CATEGORY';
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
use Joomla\CMS\Session\Session;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for exporting locations to CSV.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class ExportController extends BaseController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Export locations as a CSV download.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function execute(): void
|
|
||||||
{
|
|
||||||
Session::checkToken('get') or die(Text::_('JINVALID_TOKEN'));
|
|
||||||
|
|
||||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
||||||
$query = $db->getQuery(true);
|
|
||||||
|
|
||||||
$query->select('a.*')
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'))
|
|
||||||
->order($db->quoteName('a.title') . ' ASC');
|
|
||||||
|
|
||||||
// Apply filters from request
|
|
||||||
$app = Factory::getApplication();
|
|
||||||
$published = $app->getInput()->getInt('filter_published', null);
|
|
||||||
|
|
||||||
if ($published !== null)
|
|
||||||
{
|
|
||||||
$query->where($db->quoteName('a.published') . ' = :published')
|
|
||||||
->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER);
|
|
||||||
}
|
|
||||||
|
|
||||||
$city = $app->getInput()->getString('filter_city', '');
|
|
||||||
|
|
||||||
if ($city)
|
|
||||||
{
|
|
||||||
$query->where($db->quoteName('a.city') . ' = :city')
|
|
||||||
->bind(':city', $city);
|
|
||||||
}
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
$locations = $db->loadObjectList();
|
|
||||||
|
|
||||||
// CSV columns
|
|
||||||
$columns = [
|
|
||||||
'id', 'title', 'alias', 'description', 'address', 'city', 'state',
|
|
||||||
'postcode', 'country', 'latitude', 'longitude', 'phone', 'email',
|
|
||||||
'website', 'hours', 'published',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Output CSV
|
|
||||||
$app = Factory::getApplication();
|
|
||||||
$app->setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
||||||
$app->setHeader('Content-Disposition', 'attachment; filename="store-locations-' . date('Y-m-d') . '.csv"');
|
|
||||||
$app->setHeader('Cache-Control', 'no-cache, must-revalidate');
|
|
||||||
$app->sendHeaders();
|
|
||||||
|
|
||||||
$output = fopen('php://output', 'w');
|
|
||||||
|
|
||||||
// BOM for Excel UTF-8 compatibility
|
|
||||||
fwrite($output, "\xEF\xBB\xBF");
|
|
||||||
|
|
||||||
// Header row
|
|
||||||
fputcsv($output, $columns);
|
|
||||||
|
|
||||||
// Data rows
|
|
||||||
foreach ($locations as $location)
|
|
||||||
{
|
|
||||||
$row = [];
|
|
||||||
|
|
||||||
foreach ($columns as $col)
|
|
||||||
{
|
|
||||||
$row[] = $location->$col ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
fputcsv($output, $row);
|
|
||||||
}
|
|
||||||
|
|
||||||
fclose($output);
|
|
||||||
|
|
||||||
$app->close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Log\Log;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
use Joomla\CMS\Session\Session;
|
|
||||||
use Moko\Component\MokoJoomStoreLocator\Administrator\Helper\Geocoder;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for importing locations from CSV files.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class ImportController extends BaseController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Process the uploaded CSV file and import locations.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function execute(): void
|
|
||||||
{
|
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
|
||||||
|
|
||||||
$app = Factory::getApplication();
|
|
||||||
$input = $app->getInput();
|
|
||||||
$file = $input->files->get('import_file', [], 'array');
|
|
||||||
|
|
||||||
if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK)
|
|
||||||
{
|
|
||||||
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_NO_FILE'), 'error');
|
|
||||||
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=import', false));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
||||||
|
|
||||||
if ($ext !== 'csv')
|
|
||||||
{
|
|
||||||
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FORMAT'), 'error');
|
|
||||||
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=import', false));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$geocodeOnImport = (bool) $input->getInt('geocode', 0);
|
|
||||||
$updateExisting = (bool) $input->getInt('update_existing', 0);
|
|
||||||
|
|
||||||
// Column mapping from the enhanced import UI (JSON string: {"0":"title","1":"address",...})
|
|
||||||
$columnMapJson = $input->getString('column_map', '');
|
|
||||||
$columnMap = $columnMapJson ? json_decode($columnMapJson, true) : null;
|
|
||||||
|
|
||||||
$result = $this->processCSV($file['tmp_name'], $geocodeOnImport, $updateExisting, $columnMap);
|
|
||||||
|
|
||||||
$app->enqueueMessage(
|
|
||||||
Text::sprintf('COM_MOKOJOOMSTORELOCATOR_IMPORT_RESULT', $result['imported'], $result['updated'], $result['skipped']),
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
|
|
||||||
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=locations', false));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse and import a CSV file.
|
|
||||||
*
|
|
||||||
* Expected columns: title, address, city, state, postcode, country, latitude, longitude,
|
|
||||||
* phone, email, website, hours, description
|
|
||||||
*
|
|
||||||
* @param string $filePath Path to the CSV file.
|
|
||||||
* @param bool $geocodeOnImport Whether to geocode missing coordinates.
|
|
||||||
* @param bool $updateExisting Whether to update existing records by title match.
|
|
||||||
* @param array|null $columnMap Column mapping from UI: {"csv_index":"field_name",...}
|
|
||||||
*
|
|
||||||
* @return array ['imported' => int, 'updated' => int, 'skipped' => int]
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
private function processCSV(string $filePath, bool $geocodeOnImport, bool $updateExisting, ?array $columnMap = null): array
|
|
||||||
{
|
|
||||||
$handle = fopen($filePath, 'r');
|
|
||||||
|
|
||||||
if (!$handle)
|
|
||||||
{
|
|
||||||
return ['imported' => 0, 'updated' => 0, 'skipped' => 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read header row
|
|
||||||
$headers = fgetcsv($handle);
|
|
||||||
|
|
||||||
if (!$headers)
|
|
||||||
{
|
|
||||||
fclose($handle);
|
|
||||||
|
|
||||||
return ['imported' => 0, 'updated' => 0, 'skipped' => 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// If column map provided from UI, use it; otherwise fall back to header-based mapping
|
|
||||||
if ($columnMap)
|
|
||||||
{
|
|
||||||
// columnMap is {"csv_index": "field_name", ...}
|
|
||||||
$useColumnMap = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$headers = array_map('strtolower', array_map('trim', $headers));
|
|
||||||
$useColumnMap = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
||||||
$geocoder = $geocodeOnImport ? new Geocoder() : null;
|
|
||||||
$imported = 0;
|
|
||||||
$updated = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
|
|
||||||
while (($row = fgetcsv($handle)) !== false)
|
|
||||||
{
|
|
||||||
if (count($row) < 2)
|
|
||||||
{
|
|
||||||
$skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($useColumnMap)
|
|
||||||
{
|
|
||||||
$data = [];
|
|
||||||
|
|
||||||
foreach ($columnMap as $csvIndex => $fieldName)
|
|
||||||
{
|
|
||||||
$data[$fieldName] = $row[(int) $csvIndex] ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$data = @array_combine($headers, $row) ?: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($data['title']))
|
|
||||||
{
|
|
||||||
$skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for existing record
|
|
||||||
$existingId = null;
|
|
||||||
|
|
||||||
if ($updateExisting)
|
|
||||||
{
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select('id')
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_locations'))
|
|
||||||
->where($db->quoteName('title') . ' = :title')
|
|
||||||
->bind(':title', $data['title']);
|
|
||||||
$db->setQuery($query);
|
|
||||||
$existingId = $db->loadResult();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Geocode if needed
|
|
||||||
if ($geocoder && empty($data['latitude']) && empty($data['longitude']))
|
|
||||||
{
|
|
||||||
$coords = $geocoder->geocode(
|
|
||||||
$data['address'] ?? '',
|
|
||||||
$data['city'] ?? '',
|
|
||||||
$data['state'] ?? '',
|
|
||||||
$data['postcode'] ?? '',
|
|
||||||
$data['country'] ?? ''
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($coords)
|
|
||||||
{
|
|
||||||
$data['latitude'] = $coords['lat'];
|
|
||||||
$data['longitude'] = $coords['lng'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build table record
|
|
||||||
$table = $this->getModel('Location')->getTable();
|
|
||||||
|
|
||||||
if ($existingId)
|
|
||||||
{
|
|
||||||
$table->load($existingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$locationData = [
|
|
||||||
'title' => $data['title'] ?? '',
|
|
||||||
'address' => $data['address'] ?? '',
|
|
||||||
'city' => $data['city'] ?? '',
|
|
||||||
'state' => $data['state'] ?? '',
|
|
||||||
'postcode' => $data['postcode'] ?? '',
|
|
||||||
'country' => $data['country'] ?? '',
|
|
||||||
'latitude' => !empty($data['latitude']) ? (float) $data['latitude'] : null,
|
|
||||||
'longitude' => !empty($data['longitude']) ? (float) $data['longitude'] : null,
|
|
||||||
'phone' => $data['phone'] ?? '',
|
|
||||||
'email' => $data['email'] ?? '',
|
|
||||||
'website' => $data['website'] ?? '',
|
|
||||||
'hours' => $data['hours'] ?? '',
|
|
||||||
'description' => $data['description'] ?? '',
|
|
||||||
'published' => 1,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!$table->bind($locationData))
|
|
||||||
{
|
|
||||||
$skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$table->check())
|
|
||||||
{
|
|
||||||
$skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$table->store())
|
|
||||||
{
|
|
||||||
Log::add('Import failed for: ' . $data['title'] . ' — ' . $table->getError(), Log::WARNING, 'com_mokojoomstorelocator');
|
|
||||||
$skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($existingId)
|
|
||||||
{
|
|
||||||
$updated++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$imported++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fclose($handle);
|
|
||||||
|
|
||||||
return ['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Controller\FormController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for a single location record.
|
|
||||||
*
|
|
||||||
* Handles save, cancel, edit actions for the location edit form.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class LocationController extends FormController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The prefix for the model class name.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected $text_prefix = 'COM_MOKOJOOMSTORELOCATOR_LOCATION';
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Controller\AdminController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for the locations list.
|
|
||||||
*
|
|
||||||
* Handles publish, unpublish, delete, and ordering actions on the list view.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class LocationsController extends AdminController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Proxy for getModel.
|
|
||||||
*
|
|
||||||
* @param string $name The model name.
|
|
||||||
* @param string $prefix The model prefix.
|
|
||||||
* @param array $config Configuration array.
|
|
||||||
*
|
|
||||||
* @return \Joomla\CMS\MVC\Model\BaseDatabaseModel
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function getModel($name = 'Location', $prefix = 'Administrator', $config = ['ignore_request' => true])
|
|
||||||
{
|
|
||||||
return parent::getModel($name, $prefix, $config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Log\Log;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
use Joomla\CMS\Session\Session;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for sample data operations.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class SampledataController extends BaseController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Download a CSV template file with column headers and a sample row.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function download(): void
|
|
||||||
{
|
|
||||||
Session::checkToken('get') or die(Text::_('JINVALID_TOKEN'));
|
|
||||||
|
|
||||||
$app = Factory::getApplication();
|
|
||||||
$app->setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
||||||
$app->setHeader('Content-Disposition', 'attachment; filename="store-locations-template.csv"');
|
|
||||||
$app->sendHeaders();
|
|
||||||
|
|
||||||
$output = fopen('php://output', 'w');
|
|
||||||
fwrite($output, "\xEF\xBB\xBF");
|
|
||||||
|
|
||||||
$headers = ['title', 'address', 'city', 'state', 'postcode', 'country', 'latitude', 'longitude', 'phone', 'email', 'website', 'hours', 'description'];
|
|
||||||
fputcsv($output, $headers);
|
|
||||||
|
|
||||||
fputcsv($output, [
|
|
||||||
'Moko HQ',
|
|
||||||
'123 Main Street',
|
|
||||||
'Nashville',
|
|
||||||
'TN',
|
|
||||||
'37201',
|
|
||||||
'US',
|
|
||||||
'36.1627',
|
|
||||||
'-86.7816',
|
|
||||||
'(615) 555-0100',
|
|
||||||
'hello@example.com',
|
|
||||||
'https://example.com',
|
|
||||||
'Mon-Fri 9am-5pm',
|
|
||||||
'Our main office location.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
fclose($output);
|
|
||||||
$app->close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject sample location data into the database for testing.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function inject(): void
|
|
||||||
{
|
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
|
||||||
|
|
||||||
$app = Factory::getApplication();
|
|
||||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
||||||
$now = Factory::getDate()->toSql();
|
|
||||||
$uid = Factory::getApplication()->getIdentity()->id ?? 0;
|
|
||||||
|
|
||||||
$samples = [
|
|
||||||
[
|
|
||||||
'title' => 'Downtown Nashville Store',
|
|
||||||
'alias' => 'downtown-nashville-store',
|
|
||||||
'description' => 'Our flagship location in the heart of downtown Nashville.',
|
|
||||||
'address' => '200 Broadway',
|
|
||||||
'city' => 'Nashville',
|
|
||||||
'state' => 'TN',
|
|
||||||
'postcode' => '37201',
|
|
||||||
'country' => 'US',
|
|
||||||
'latitude' => 36.1622,
|
|
||||||
'longitude' => -86.7744,
|
|
||||||
'phone' => '(615) 555-0101',
|
|
||||||
'email' => 'downtown@example.com',
|
|
||||||
'website' => 'https://example.com/downtown',
|
|
||||||
'hours' => "Mon-Fri: 9am-7pm\nSat: 10am-6pm\nSun: 12pm-5pm",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'East Nashville Location',
|
|
||||||
'alias' => 'east-nashville-location',
|
|
||||||
'description' => 'Serving the East Nashville community with friendly service.',
|
|
||||||
'address' => '1000 Main St',
|
|
||||||
'city' => 'Nashville',
|
|
||||||
'state' => 'TN',
|
|
||||||
'postcode' => '37206',
|
|
||||||
'country' => 'US',
|
|
||||||
'latitude' => 36.1781,
|
|
||||||
'longitude' => -86.7534,
|
|
||||||
'phone' => '(615) 555-0102',
|
|
||||||
'email' => 'east@example.com',
|
|
||||||
'website' => 'https://example.com/east',
|
|
||||||
'hours' => "Mon-Sat: 10am-8pm\nSun: Closed",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Franklin Square',
|
|
||||||
'alias' => 'franklin-square',
|
|
||||||
'description' => 'Conveniently located in the Franklin town square.',
|
|
||||||
'address' => '400 Main St',
|
|
||||||
'city' => 'Franklin',
|
|
||||||
'state' => 'TN',
|
|
||||||
'postcode' => '37064',
|
|
||||||
'country' => 'US',
|
|
||||||
'latitude' => 35.9251,
|
|
||||||
'longitude' => -86.8689,
|
|
||||||
'phone' => '(615) 555-0103',
|
|
||||||
'email' => 'franklin@example.com',
|
|
||||||
'website' => 'https://example.com/franklin',
|
|
||||||
'hours' => "Mon-Fri: 8am-6pm\nSat-Sun: 10am-4pm",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Murfreesboro Plaza',
|
|
||||||
'alias' => 'murfreesboro-plaza',
|
|
||||||
'description' => 'Our newest location serving Rutherford County.',
|
|
||||||
'address' => '1720 Old Fort Pkwy',
|
|
||||||
'city' => 'Murfreesboro',
|
|
||||||
'state' => 'TN',
|
|
||||||
'postcode' => '37129',
|
|
||||||
'country' => 'US',
|
|
||||||
'latitude' => 35.8353,
|
|
||||||
'longitude' => -86.4160,
|
|
||||||
'phone' => '(615) 555-0104',
|
|
||||||
'email' => 'murfreesboro@example.com',
|
|
||||||
'website' => 'https://example.com/murfreesboro',
|
|
||||||
'hours' => "Mon-Sat: 9am-9pm\nSun: 11am-6pm",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Clarksville Center',
|
|
||||||
'alias' => 'clarksville-center',
|
|
||||||
'description' => 'Serving the Clarksville-Montgomery County area.',
|
|
||||||
'address' => '2801 Wilma Rudolph Blvd',
|
|
||||||
'city' => 'Clarksville',
|
|
||||||
'state' => 'TN',
|
|
||||||
'postcode' => '37040',
|
|
||||||
'country' => 'US',
|
|
||||||
'latitude' => 36.5843,
|
|
||||||
'longitude' => -87.3199,
|
|
||||||
'phone' => '(931) 555-0105',
|
|
||||||
'email' => 'clarksville@example.com',
|
|
||||||
'website' => 'https://example.com/clarksville',
|
|
||||||
'hours' => "Mon-Fri: 9am-7pm\nSat: 10am-5pm\nSun: Closed",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Chattanooga Riverfront',
|
|
||||||
'alias' => 'chattanooga-riverfront',
|
|
||||||
'description' => 'Located near the Tennessee Aquarium on the riverfront.',
|
|
||||||
'address' => '1 Broad St',
|
|
||||||
'city' => 'Chattanooga',
|
|
||||||
'state' => 'TN',
|
|
||||||
'postcode' => '37402',
|
|
||||||
'country' => 'US',
|
|
||||||
'latitude' => 35.0557,
|
|
||||||
'longitude' => -85.3097,
|
|
||||||
'phone' => '(423) 555-0106',
|
|
||||||
'email' => 'chattanooga@example.com',
|
|
||||||
'website' => 'https://example.com/chattanooga',
|
|
||||||
'hours' => "Daily: 10am-8pm",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Knoxville Market Square',
|
|
||||||
'alias' => 'knoxville-market-square',
|
|
||||||
'description' => 'In the heart of downtown Knoxville at Market Square.',
|
|
||||||
'address' => '36 Market Square',
|
|
||||||
'city' => 'Knoxville',
|
|
||||||
'state' => 'TN',
|
|
||||||
'postcode' => '37902',
|
|
||||||
'country' => 'US',
|
|
||||||
'latitude' => 35.9643,
|
|
||||||
'longitude' => -83.9198,
|
|
||||||
'phone' => '(865) 555-0107',
|
|
||||||
'email' => 'knoxville@example.com',
|
|
||||||
'website' => 'https://example.com/knoxville',
|
|
||||||
'hours' => "Mon-Sat: 9am-8pm\nSun: 12pm-6pm",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Memphis Beale Street',
|
|
||||||
'alias' => 'memphis-beale-street',
|
|
||||||
'description' => 'Right on iconic Beale Street in downtown Memphis.',
|
|
||||||
'address' => '152 Beale St',
|
|
||||||
'city' => 'Memphis',
|
|
||||||
'state' => 'TN',
|
|
||||||
'postcode' => '38103',
|
|
||||||
'country' => 'US',
|
|
||||||
'latitude' => 35.1393,
|
|
||||||
'longitude' => -90.0530,
|
|
||||||
'phone' => '(901) 555-0108',
|
|
||||||
'email' => 'memphis@example.com',
|
|
||||||
'website' => 'https://example.com/memphis',
|
|
||||||
'hours' => "Mon-Thu: 10am-10pm\nFri-Sat: 10am-12am\nSun: 11am-8pm",
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$inserted = 0;
|
|
||||||
|
|
||||||
foreach ($samples as $sample)
|
|
||||||
{
|
|
||||||
$sample['published'] = 1;
|
|
||||||
$sample['ordering'] = $inserted + 1;
|
|
||||||
$sample['params'] = '{}';
|
|
||||||
$sample['image'] = '';
|
|
||||||
$sample['catid'] = 0;
|
|
||||||
$sample['created'] = $now;
|
|
||||||
$sample['created_by'] = $uid;
|
|
||||||
$sample['modified'] = $now;
|
|
||||||
$sample['modified_by'] = $uid;
|
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select('COUNT(*)')
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_locations'))
|
|
||||||
->where($db->quoteName('alias') . ' = :alias')
|
|
||||||
->bind(':alias', $sample['alias']);
|
|
||||||
$db->setQuery($query);
|
|
||||||
|
|
||||||
if ($db->loadResult() > 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->insert($db->quoteName('#__mokojoomstorelocator_locations'))
|
|
||||||
->columns($db->quoteName(array_keys($sample)))
|
|
||||||
->values(implode(',', array_map(function ($v) use ($db) {
|
|
||||||
return $db->quote($v);
|
|
||||||
}, array_values($sample))));
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
$db->execute();
|
|
||||||
$inserted++;
|
|
||||||
}
|
|
||||||
catch (\Exception $e)
|
|
||||||
{
|
|
||||||
Log::add('Sample data insert failed: ' . $e->getMessage(), Log::WARNING, 'com_mokojoomstorelocator');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$app->enqueueMessage(
|
|
||||||
Text::sprintf('COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECTED', $inserted),
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
|
|
||||||
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=locations', false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-54
@@ -11,65 +11,13 @@ namespace Moko\Component\MokoJoomStoreLocator\Administrator\Extension;
|
|||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Extension\MVCComponent;
|
use Joomla\CMS\Extension\MVCComponent;
|
||||||
use Joomla\CMS\Component\Router\RouterServiceInterface;
|
|
||||||
use Joomla\CMS\Component\Router\RouterServiceTrait;
|
|
||||||
use Joomla\CMS\Fields\FieldsServiceInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component class for com_mokojoomstorelocator.
|
* Component class for com_mokojoomstorelocator.
|
||||||
*
|
*
|
||||||
* Implements RouterServiceInterface for SEF URLs and FieldsServiceInterface
|
|
||||||
* for Joomla custom fields (com_fields) integration.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
class MokoJoomStoreLocatorComponent extends MVCComponent implements RouterServiceInterface, FieldsServiceInterface
|
class MokoJoomStoreLocatorComponent extends MVCComponent
|
||||||
{
|
{
|
||||||
use RouterServiceTrait;
|
// TODO: Add boot(), getRouterRules(), or custom services as needed
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the contexts available for custom fields.
|
|
||||||
*
|
|
||||||
* @return string[] Array of context names.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function getContexts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'com_mokojoomstorelocator.location' => 'COM_MOKOJOOMSTORELOCATOR_FIELDS_LOCATION',
|
|
||||||
'com_mokojoomstorelocator.category' => 'COM_MOKOJOOMSTORELOCATOR_FIELDS_CATEGORY',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a field context.
|
|
||||||
*
|
|
||||||
* @param string $context The context to validate.
|
|
||||||
*
|
|
||||||
* @return array|null Validated context parts or null.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function validateSection($section, $item = null): ?string
|
|
||||||
{
|
|
||||||
if ($section === 'location' || $section === 'category')
|
|
||||||
{
|
|
||||||
return $section;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns valid sections for custom fields.
|
|
||||||
*
|
|
||||||
* @return array Section names.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function getValidSections(): array
|
|
||||||
{
|
|
||||||
return ['location', 'category'];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Field;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Form\Field\ListField;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-select field for assigning categories to a location.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class LocationCategoriesField extends ListField
|
|
||||||
{
|
|
||||||
protected $type = 'LocationCategories';
|
|
||||||
|
|
||||||
protected function getOptions(): array
|
|
||||||
{
|
|
||||||
$options = parent::getOptions();
|
|
||||||
|
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select([$db->quoteName('id', 'value'), $db->quoteName('title', 'text')])
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_categories'))
|
|
||||||
->where($db->quoteName('published') . ' = 1')
|
|
||||||
->order($db->quoteName('title') . ' ASC');
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
$categories = $db->loadObjectList();
|
|
||||||
|
|
||||||
foreach ($categories as $cat)
|
|
||||||
{
|
|
||||||
$options[] = (object) [
|
|
||||||
'value' => $cat->value,
|
|
||||||
'text' => $cat->text,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $options;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Helper;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Component\ComponentHelper;
|
|
||||||
use Joomla\CMS\Http\HttpFactory;
|
|
||||||
use Joomla\CMS\Log\Log;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Geocoding helper — converts addresses to latitude/longitude coordinates.
|
|
||||||
*
|
|
||||||
* Supports Nominatim (OpenStreetMap, free, default) and Google Geocoding API.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class Geocoder
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var string Geocoding provider: 'nominatim' or 'google'.
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
private string $provider;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Google Geocoding API key (only for google provider).
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
private string $apiKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$params = ComponentHelper::getParams('com_mokojoomstorelocator');
|
|
||||||
|
|
||||||
$this->provider = $params->get('geocoder_provider', 'nominatim');
|
|
||||||
$this->apiKey = $params->get('google_api_key', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Geocode an address string to coordinates.
|
|
||||||
*
|
|
||||||
* @param string $address Street address.
|
|
||||||
* @param string $city City.
|
|
||||||
* @param string $state State/province.
|
|
||||||
* @param string $postcode Postal code.
|
|
||||||
* @param string $country Country.
|
|
||||||
*
|
|
||||||
* @return array|null ['lat' => float, 'lng' => float] or null on failure.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function geocode(string $address = '', string $city = '', string $state = '', string $postcode = '', string $country = ''): ?array
|
|
||||||
{
|
|
||||||
$parts = array_filter([$address, $city, $state, $postcode, $country]);
|
|
||||||
$query = implode(', ', $parts);
|
|
||||||
|
|
||||||
if (empty($query))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->provider === 'google' && $this->apiKey)
|
|
||||||
{
|
|
||||||
return $this->geocodeGoogle($query);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->geocodeNominatim($query);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch geocode multiple locations.
|
|
||||||
*
|
|
||||||
* @param array $locations Array of arrays with address fields.
|
|
||||||
*
|
|
||||||
* @return array Array of results indexed by input key: ['lat' => float, 'lng' => float] or null.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function batchGeocode(array $locations): array
|
|
||||||
{
|
|
||||||
$results = [];
|
|
||||||
|
|
||||||
foreach ($locations as $key => $loc)
|
|
||||||
{
|
|
||||||
$results[$key] = $this->geocode(
|
|
||||||
$loc['address'] ?? '',
|
|
||||||
$loc['city'] ?? '',
|
|
||||||
$loc['state'] ?? '',
|
|
||||||
$loc['postcode'] ?? '',
|
|
||||||
$loc['country'] ?? ''
|
|
||||||
);
|
|
||||||
|
|
||||||
// Nominatim rate limit: max 1 request per second
|
|
||||||
if ($this->provider === 'nominatim')
|
|
||||||
{
|
|
||||||
usleep(1100000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Geocode using OpenStreetMap Nominatim (free, no API key).
|
|
||||||
*
|
|
||||||
* @param string $query The full address string.
|
|
||||||
*
|
|
||||||
* @return array|null Coordinates or null.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
private function geocodeNominatim(string $query): ?array
|
|
||||||
{
|
|
||||||
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
|
|
||||||
'q' => $query,
|
|
||||||
'format' => 'json',
|
|
||||||
'limit' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = $this->httpGet($url, [
|
|
||||||
'User-Agent' => 'MokoJoomStoreLocator/1.0 (Joomla component)',
|
|
||||||
'Accept' => 'application/json',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($result === null || empty($result))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$first = $result[0] ?? null;
|
|
||||||
|
|
||||||
if (!$first || !isset($first['lat'], $first['lon']))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'lat' => (float) $first['lat'],
|
|
||||||
'lng' => (float) $first['lon'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Geocode using Google Geocoding API.
|
|
||||||
*
|
|
||||||
* @param string $query The full address string.
|
|
||||||
*
|
|
||||||
* @return array|null Coordinates or null.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
private function geocodeGoogle(string $query): ?array
|
|
||||||
{
|
|
||||||
$url = 'https://maps.googleapis.com/maps/api/geocode/json?' . http_build_query([
|
|
||||||
'address' => $query,
|
|
||||||
'key' => $this->apiKey,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = $this->httpGet($url);
|
|
||||||
|
|
||||||
if ($result === null || ($result['status'] ?? '') !== 'OK')
|
|
||||||
{
|
|
||||||
$status = $result['status'] ?? 'unknown';
|
|
||||||
Log::add("Geocoder: Google returned status $status for: $query", Log::WARNING, 'com_mokojoomstorelocator');
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$location = $result['results'][0]['geometry']['location'] ?? null;
|
|
||||||
|
|
||||||
if (!$location)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'lat' => (float) $location['lat'],
|
|
||||||
'lng' => (float) $location['lng'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform an HTTP GET request and return decoded JSON.
|
|
||||||
*
|
|
||||||
* @param string $url The URL to fetch.
|
|
||||||
* @param array $headers Additional headers.
|
|
||||||
*
|
|
||||||
* @return array|null Decoded JSON or null on error.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
private function httpGet(string $url, array $headers = []): ?array
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
$http = HttpFactory::getHttp();
|
|
||||||
$response = $http->get($url, $headers, 15);
|
|
||||||
|
|
||||||
if ($response->code !== 200)
|
|
||||||
{
|
|
||||||
Log::add("Geocoder: HTTP {$response->code} from $url", Log::WARNING, 'com_mokojoomstorelocator');
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return json_decode($response->body, true);
|
|
||||||
}
|
|
||||||
catch (\Exception $e)
|
|
||||||
{
|
|
||||||
Log::add('Geocoder: ' . $e->getMessage(), Log::ERROR, 'com_mokojoomstorelocator');
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Helper;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper for parsing video URLs into embeddable iframes.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class VideoHelper
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Convert a YouTube or Vimeo URL to an embed URL.
|
|
||||||
*
|
|
||||||
* @param string $url The video URL.
|
|
||||||
*
|
|
||||||
* @return string|null The embed URL or null if not recognized.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public static function getEmbedUrl(string $url): ?string
|
|
||||||
{
|
|
||||||
$url = trim($url);
|
|
||||||
|
|
||||||
if (empty($url))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// YouTube
|
|
||||||
if (preg_match('/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/', $url, $m))
|
|
||||||
{
|
|
||||||
return 'https://www.youtube-nocookie.com/embed/' . $m[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vimeo
|
|
||||||
if (preg_match('/vimeo\.com\/(\d+)/', $url, $m))
|
|
||||||
{
|
|
||||||
return 'https://player.vimeo.com/video/' . $m[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\ListModel;
|
|
||||||
use Joomla\Database\QueryInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Categories list model.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class CategoriesModel extends ListModel
|
|
||||||
{
|
|
||||||
public function __construct($config = [])
|
|
||||||
{
|
|
||||||
if (empty($config['filter_fields']))
|
|
||||||
{
|
|
||||||
$config['filter_fields'] = [
|
|
||||||
'id', 'a.id',
|
|
||||||
'title', 'a.title',
|
|
||||||
'published', 'a.published',
|
|
||||||
'ordering', 'a.ordering',
|
|
||||||
'parent_id', 'a.parent_id',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::__construct($config);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getListQuery(): QueryInterface
|
|
||||||
{
|
|
||||||
$db = $this->getDatabase();
|
|
||||||
$query = $db->getQuery(true);
|
|
||||||
|
|
||||||
$query->select([
|
|
||||||
'a.*',
|
|
||||||
'p.title AS parent_title',
|
|
||||||
'(SELECT COUNT(*) FROM ' . $db->quoteName('#__mokojoomstorelocator_location_categories', 'lc')
|
|
||||||
. ' WHERE lc.category_id = a.id) AS location_count',
|
|
||||||
])
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_categories', 'a'))
|
|
||||||
->join('LEFT', $db->quoteName('#__mokojoomstorelocator_categories', 'p') . ' ON p.id = a.parent_id');
|
|
||||||
|
|
||||||
$published = $this->getState('filter.published');
|
|
||||||
|
|
||||||
if (is_numeric($published))
|
|
||||||
{
|
|
||||||
$query->where($db->quoteName('a.published') . ' = :published')
|
|
||||||
->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER);
|
|
||||||
}
|
|
||||||
elseif ($published === '')
|
|
||||||
{
|
|
||||||
$query->where($db->quoteName('a.published') . ' IN (0, 1)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$search = $this->getState('filter.search');
|
|
||||||
|
|
||||||
if (!empty($search))
|
|
||||||
{
|
|
||||||
$search = '%' . trim($search) . '%';
|
|
||||||
$query->where($db->quoteName('a.title') . ' LIKE :search')
|
|
||||||
->bind(':search', $search);
|
|
||||||
}
|
|
||||||
|
|
||||||
$orderCol = $this->state->get('list.ordering', 'a.ordering');
|
|
||||||
$orderDir = $this->state->get('list.direction', 'ASC');
|
|
||||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\AdminModel;
|
|
||||||
use Joomla\CMS\Form\Form;
|
|
||||||
use Joomla\CMS\Table\Table;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single category edit model.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class CategoryModel extends AdminModel
|
|
||||||
{
|
|
||||||
public $typeAlias = 'com_mokojoomstorelocator.category';
|
|
||||||
|
|
||||||
public function getForm($data = [], $loadData = true)
|
|
||||||
{
|
|
||||||
$form = $this->loadForm(
|
|
||||||
'com_mokojoomstorelocator.category',
|
|
||||||
'category',
|
|
||||||
['control' => 'jform', 'load_data' => $loadData]
|
|
||||||
);
|
|
||||||
|
|
||||||
return empty($form) ? false : $form;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function loadFormData()
|
|
||||||
{
|
|
||||||
return $this->getItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTable($name = 'Category', $prefix = 'Administrator', $options = [])
|
|
||||||
{
|
|
||||||
return parent::getTable($name, $prefix, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save category and update the location-category junction table.
|
|
||||||
*
|
|
||||||
* @param array $data The form data.
|
|
||||||
*
|
|
||||||
* @return boolean
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function save($data)
|
|
||||||
{
|
|
||||||
return parent::save($data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,22 +10,35 @@ namespace Moko\Component\MokoJoomStoreLocator\Administrator\Model;
|
|||||||
|
|
||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\Model\AdminModel;
|
use Joomla\CMS\MVC\Model\AdminModel;
|
||||||
use Joomla\CMS\Form\Form;
|
use Joomla\CMS\Form\Form;
|
||||||
use Joomla\CMS\Table\Table;
|
use Joomla\CMS\Table\Table;
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
use Joomla\Database\ParameterType;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single location edit model with multi-category support.
|
* Single location edit model.
|
||||||
*
|
*
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
class LocationModel extends AdminModel
|
class LocationModel extends AdminModel
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* The type alias for this content type.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
public $typeAlias = 'com_mokojoomstorelocator.location';
|
public $typeAlias = 'com_mokojoomstorelocator.location';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the form for this model.
|
||||||
|
*
|
||||||
|
* @param array $data Data for the form.
|
||||||
|
* @param boolean $loadData True if the form is to load its own data.
|
||||||
|
*
|
||||||
|
* @return Form|boolean A Form object on success, false on failure.
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
public function getForm($data = [], $loadData = true)
|
public function getForm($data = [], $loadData = true)
|
||||||
{
|
{
|
||||||
$form = $this->loadForm(
|
$form = $this->loadForm(
|
||||||
@@ -34,119 +47,41 @@ class LocationModel extends AdminModel
|
|||||||
['control' => 'jform', 'load_data' => $loadData]
|
['control' => 'jform', 'load_data' => $loadData]
|
||||||
);
|
);
|
||||||
|
|
||||||
return empty($form) ? false : $form;
|
if (empty($form))
|
||||||
}
|
|
||||||
|
|
||||||
protected function loadFormData()
|
|
||||||
{
|
|
||||||
$data = $this->getItem();
|
|
||||||
|
|
||||||
// Load assigned category IDs for the multi-select field
|
|
||||||
if ($data && $data->id)
|
|
||||||
{
|
|
||||||
$data->category_ids = $this->getCategoryIds((int) $data->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTable($name = 'Location', $prefix = 'Administrator', $options = [])
|
|
||||||
{
|
|
||||||
return parent::getTable($name, $prefix, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Override save to handle the many-to-many category relationship.
|
|
||||||
*
|
|
||||||
* @param array $data The form data.
|
|
||||||
*
|
|
||||||
* @return boolean
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function save($data)
|
|
||||||
{
|
|
||||||
$categoryIds = $data['category_ids'] ?? [];
|
|
||||||
unset($data['category_ids']);
|
|
||||||
|
|
||||||
if (!parent::save($data))
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$locationId = (int) $this->getState($this->getName() . '.id');
|
return $form;
|
||||||
|
|
||||||
$this->saveCategoryAssignments($locationId, (array) $categoryIds);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get category IDs assigned to a location.
|
* Load the data for the form.
|
||||||
*
|
*
|
||||||
* @param int $locationId The location ID.
|
* @return mixed The data for the form.
|
||||||
*
|
|
||||||
* @return array Array of category IDs.
|
|
||||||
*
|
*
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
public function getCategoryIds(int $locationId): array
|
protected function loadFormData()
|
||||||
{
|
{
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
$data = $this->getItem();
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select($db->quoteName('category_id'))
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_location_categories'))
|
|
||||||
->where($db->quoteName('location_id') . ' = :id')
|
|
||||||
->bind(':id', $locationId, ParameterType::INTEGER);
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
return $data;
|
||||||
|
|
||||||
return $db->loadColumn() ?: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save category assignments for a location (replace all).
|
* Get the table for this model.
|
||||||
*
|
*
|
||||||
* @param int $locationId The location ID.
|
* @param string $name The table name.
|
||||||
* @param array $categoryIds Array of category IDs to assign.
|
* @param string $prefix The table prefix.
|
||||||
|
* @param array $options Configuration array for the table.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return Table
|
||||||
*
|
*
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
private function saveCategoryAssignments(int $locationId, array $categoryIds): void
|
public function getTable($name = 'Location', $prefix = 'Administrator', $options = [])
|
||||||
{
|
{
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
return parent::getTable($name, $prefix, $options);
|
||||||
|
|
||||||
// Delete existing assignments
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->delete($db->quoteName('#__mokojoomstorelocator_location_categories'))
|
|
||||||
->where($db->quoteName('location_id') . ' = :id')
|
|
||||||
->bind(':id', $locationId, ParameterType::INTEGER);
|
|
||||||
$db->setQuery($query);
|
|
||||||
$db->execute();
|
|
||||||
|
|
||||||
// Insert new assignments
|
|
||||||
if (empty($categoryIds))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->insert($db->quoteName('#__mokojoomstorelocator_location_categories'))
|
|
||||||
->columns([$db->quoteName('location_id'), $db->quoteName('category_id')]);
|
|
||||||
|
|
||||||
foreach ($categoryIds as $catId)
|
|
||||||
{
|
|
||||||
$catId = (int) $catId;
|
|
||||||
|
|
||||||
if ($catId > 0)
|
|
||||||
{
|
|
||||||
$query->values($locationId . ',' . $catId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
$db->execute();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,20 +44,6 @@ class LocationsModel extends ListModel
|
|||||||
parent::__construct($config);
|
parent::__construct($config);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function populateState($ordering = 'a.ordering', $direction = 'ASC')
|
|
||||||
{
|
|
||||||
$search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string');
|
|
||||||
$this->setState('filter.search', $search);
|
|
||||||
|
|
||||||
$published = $this->getUserStateFromRequest($this->context . '.filter.published', 'filter_published', '', 'string');
|
|
||||||
$this->setState('filter.published', $published);
|
|
||||||
|
|
||||||
$categoryId = $this->getUserStateFromRequest($this->context . '.filter.category_id', 'filter_category_id', 0, 'int');
|
|
||||||
$this->setState('filter.category_id', $categoryId);
|
|
||||||
|
|
||||||
parent::populateState($ordering, $direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an SQL query to load the list data.
|
* Build an SQL query to load the list data.
|
||||||
*
|
*
|
||||||
@@ -73,46 +59,10 @@ class LocationsModel extends ListModel
|
|||||||
$query->select('a.*')
|
$query->select('a.*')
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'));
|
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'));
|
||||||
|
|
||||||
$published = $this->getState('filter.published');
|
// TODO: Add filter by published state
|
||||||
|
// TODO: Add filter by category
|
||||||
if (is_numeric($published))
|
// TODO: Add search filter
|
||||||
{
|
// TODO: Add ordering clause
|
||||||
$query->where($db->quoteName('a.published') . ' = :published')
|
|
||||||
->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER);
|
|
||||||
}
|
|
||||||
elseif ($published === '')
|
|
||||||
{
|
|
||||||
$query->where($db->quoteName('a.published') . ' IN (0, 1)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$catId = (int) $this->getState('filter.category_id');
|
|
||||||
|
|
||||||
if ($catId > 0)
|
|
||||||
{
|
|
||||||
$query->where($db->quoteName('a.catid') . ' = :catid')
|
|
||||||
->bind(':catid', $catId, \Joomla\Database\ParameterType::INTEGER);
|
|
||||||
}
|
|
||||||
|
|
||||||
$search = $this->getState('filter.search');
|
|
||||||
|
|
||||||
if (!empty($search))
|
|
||||||
{
|
|
||||||
$search = '%' . trim($search) . '%';
|
|
||||||
$query->where(
|
|
||||||
'(' . $db->quoteName('a.title') . ' LIKE :search1'
|
|
||||||
. ' OR ' . $db->quoteName('a.city') . ' LIKE :search2'
|
|
||||||
. ' OR ' . $db->quoteName('a.address') . ' LIKE :search3'
|
|
||||||
. ' OR ' . $db->quoteName('a.postcode') . ' LIKE :search4)'
|
|
||||||
)
|
|
||||||
->bind(':search1', $search)
|
|
||||||
->bind(':search2', $search)
|
|
||||||
->bind(':search3', $search)
|
|
||||||
->bind(':search4', $search);
|
|
||||||
}
|
|
||||||
|
|
||||||
$orderCol = $this->state->get('list.ordering', 'a.ordering');
|
|
||||||
$orderDir = $this->state->get('list.direction', 'ASC');
|
|
||||||
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
|
||||||
|
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\Table;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Filter\OutputFilter;
|
|
||||||
use Joomla\CMS\Table\Table;
|
|
||||||
use Joomla\Database\DatabaseDriver;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Category table class.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class CategoryTable extends Table
|
|
||||||
{
|
|
||||||
public function __construct(DatabaseDriver $db)
|
|
||||||
{
|
|
||||||
parent::__construct('#__mokojoomstorelocator_categories', 'id', $db);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function check(): bool
|
|
||||||
{
|
|
||||||
if (empty($this->title))
|
|
||||||
{
|
|
||||||
$this->setError('A category title is required.');
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($this->alias))
|
|
||||||
{
|
|
||||||
$this->alias = $this->title;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->alias = OutputFilter::stringURLSafe($this->alias);
|
|
||||||
|
|
||||||
if (empty($this->alias))
|
|
||||||
{
|
|
||||||
$this->alias = \Joomla\CMS\Factory::getDate()->format('Y-m-d-H-i-s');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate color is a hex value
|
|
||||||
if (!empty($this->color) && !preg_match('/^#[0-9a-fA-F]{6}$/', $this->color))
|
|
||||||
{
|
|
||||||
$this->color = '#3b82f6';
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::check();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,69 +43,10 @@ class LocationTable extends Table
|
|||||||
*/
|
*/
|
||||||
public function check(): bool
|
public function check(): bool
|
||||||
{
|
{
|
||||||
if (empty($this->title))
|
// TODO: Validate title is not empty
|
||||||
{
|
// TODO: Auto-generate alias from title if empty
|
||||||
$this->setError('A location title is required.');
|
// TODO: Validate latitude/longitude ranges
|
||||||
|
// TODO: Set created/modified timestamps
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($this->alias))
|
|
||||||
{
|
|
||||||
$this->alias = $this->title;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->alias = \Joomla\CMS\Filter\OutputFilter::stringURLSafe($this->alias);
|
|
||||||
|
|
||||||
if (empty($this->alias))
|
|
||||||
{
|
|
||||||
$this->alias = \Joomla\CMS\Factory::getDate()->format('Y-m-d-H-i-s');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->latitude !== null && ($this->latitude < -90 || $this->latitude > 90))
|
|
||||||
{
|
|
||||||
$this->setError('Latitude must be between -90 and 90.');
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->longitude !== null && ($this->longitude < -180 || $this->longitude > 180))
|
|
||||||
{
|
|
||||||
$this->setError('Longitude must be between -180 and 180.');
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = \Joomla\CMS\Factory::getDate()->toSql();
|
|
||||||
|
|
||||||
if (empty($this->created) || $this->created === '0000-00-00 00:00:00')
|
|
||||||
{
|
|
||||||
$this->created = $now;
|
|
||||||
$this->created_by = \Joomla\CMS\Factory::getApplication()->getIdentity()->id ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->modified = $now;
|
|
||||||
$this->modified_by = \Joomla\CMS\Factory::getApplication()->getIdentity()->id ?? 0;
|
|
||||||
|
|
||||||
// Auto-geocode if address present but coordinates missing
|
|
||||||
if ((empty($this->latitude) || empty($this->longitude))
|
|
||||||
&& (!empty($this->address) || !empty($this->city)))
|
|
||||||
{
|
|
||||||
$geocoder = new \Moko\Component\MokoJoomStoreLocator\Administrator\Helper\Geocoder();
|
|
||||||
$coords = $geocoder->geocode(
|
|
||||||
$this->address ?? '',
|
|
||||||
$this->city ?? '',
|
|
||||||
$this->state ?? '',
|
|
||||||
$this->postcode ?? '',
|
|
||||||
$this->country ?? ''
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($coords)
|
|
||||||
{
|
|
||||||
$this->latitude = $coords['lat'];
|
|
||||||
$this->longitude = $coords['lng'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::check();
|
return parent::check();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Categories;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
protected $items;
|
|
||||||
protected $pagination;
|
|
||||||
protected $state;
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->items = $this->get('Items');
|
|
||||||
$this->pagination = $this->get('Pagination');
|
|
||||||
$this->state = $this->get('State');
|
|
||||||
|
|
||||||
ToolbarHelper::title('Store Locator: Categories');
|
|
||||||
ToolbarHelper::addNew('category.add');
|
|
||||||
ToolbarHelper::publish('categories.publish', 'JTOOLBAR_PUBLISH', true);
|
|
||||||
ToolbarHelper::unpublish('categories.unpublish', 'JTOOLBAR_UNPUBLISH', true);
|
|
||||||
ToolbarHelper::deleteList('', 'categories.delete', 'JTOOLBAR_DELETE');
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Categories;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
|
|
||||||
|
|
||||||
class JsonapiView extends BaseApiView
|
|
||||||
{
|
|
||||||
protected $fieldsToRenderItem = [
|
|
||||||
'id', 'title', 'alias', 'description', 'color', 'marker_icon', 'parent_id', 'published',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $fieldsToRenderList = [
|
|
||||||
'id', 'title', 'alias', 'color', 'parent_id', 'published',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Category;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
protected $form;
|
|
||||||
protected $item;
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->form = $this->get('Form');
|
|
||||||
$this->item = $this->get('Item');
|
|
||||||
|
|
||||||
Factory::getApplication()->getInput()->set('hidemainmenu', true);
|
|
||||||
|
|
||||||
$isNew = ($this->item->id == 0);
|
|
||||||
ToolbarHelper::title($isNew ? 'Store Locator: New Category' : 'Store Locator: Edit Category');
|
|
||||||
ToolbarHelper::apply('category.apply');
|
|
||||||
ToolbarHelper::save('category.save');
|
|
||||||
ToolbarHelper::save2new('category.save2new');
|
|
||||||
ToolbarHelper::cancel('category.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Import;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import view.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display the import form.
|
|
||||||
*
|
|
||||||
* @param string $tpl Template name.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
ToolbarHelper::title('Store Locator: Import Locations');
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Location;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Location edit view.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var \Joomla\CMS\Form\Form
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected $form;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var object
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected $item;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the view.
|
|
||||||
*
|
|
||||||
* @param string $tpl Template name.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->form = $this->get('Form');
|
|
||||||
$this->item = $this->get('Item');
|
|
||||||
|
|
||||||
$this->addToolbar();
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add the page title and toolbar.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected function addToolbar(): void
|
|
||||||
{
|
|
||||||
Factory::getApplication()->getInput()->set('hidemainmenu', true);
|
|
||||||
|
|
||||||
$isNew = ($this->item->id == 0);
|
|
||||||
|
|
||||||
ToolbarHelper::title(
|
|
||||||
$isNew ? 'Store Locator: New Location' : 'Store Locator: Edit Location'
|
|
||||||
);
|
|
||||||
|
|
||||||
ToolbarHelper::apply('location.apply');
|
|
||||||
ToolbarHelper::save('location.save');
|
|
||||||
ToolbarHelper::save2new('location.save2new');
|
|
||||||
|
|
||||||
if (!$isNew)
|
|
||||||
{
|
|
||||||
ToolbarHelper::save2copy('location.save2copy');
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolbarHelper::cancel('location.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations;
|
|||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
use Joomla\CMS\Session\Session;
|
|
||||||
use Joomla\CMS\Toolbar\Toolbar;
|
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,15 +78,5 @@ class HtmlView extends BaseHtmlView
|
|||||||
ToolbarHelper::publish('locations.publish', 'JTOOLBAR_PUBLISH', true);
|
ToolbarHelper::publish('locations.publish', 'JTOOLBAR_PUBLISH', true);
|
||||||
ToolbarHelper::unpublish('locations.unpublish', 'JTOOLBAR_UNPUBLISH', true);
|
ToolbarHelper::unpublish('locations.unpublish', 'JTOOLBAR_UNPUBLISH', true);
|
||||||
ToolbarHelper::deleteList('', 'locations.delete', 'JTOOLBAR_DELETE');
|
ToolbarHelper::deleteList('', 'locations.delete', 'JTOOLBAR_DELETE');
|
||||||
|
|
||||||
$toolbar = Toolbar::getInstance('toolbar');
|
|
||||||
|
|
||||||
$exportUrl = 'index.php?option=com_mokojoomstorelocator&task=export.execute&' . Session::getFormToken() . '=1';
|
|
||||||
$toolbar->standardButton('download', 'COM_MOKOJOOMSTORELOCATOR_EXPORT_CSV', '')->icon('icon-download')->url($exportUrl);
|
|
||||||
|
|
||||||
$sampleUrl = 'index.php?option=com_mokojoomstorelocator&task=sampledata.inject&' . Session::getFormToken() . '=1';
|
|
||||||
$toolbar->standardButton('lightning', 'COM_MOKOJOOMSTORELOCATOR_SAMPLEDATA_INJECT', '')->icon('icon-lightning')->url($sampleUrl);
|
|
||||||
|
|
||||||
ToolbarHelper::preferences('com_mokojoomstorelocator');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Administrator\View\Locations;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON API view for locations list.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class JsonapiView extends BaseApiView
|
|
||||||
{
|
|
||||||
protected $fieldsToRenderItem = [
|
|
||||||
'id', 'title', 'alias', 'description', 'address', 'city', 'state',
|
|
||||||
'postcode', 'country', 'latitude', 'longitude', 'phone', 'email',
|
|
||||||
'website', 'hours', 'image', 'published',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $fieldsToRenderList = [
|
|
||||||
'id', 'title', 'alias', 'address', 'city', 'state', 'postcode',
|
|
||||||
'latitude', 'longitude', 'phone', 'published',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\HTML\HTMLHelper;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
|
|
||||||
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Categories\HtmlView $this */
|
|
||||||
?>
|
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=categories'); ?>"
|
|
||||||
method="post" name="adminForm" id="adminForm">
|
|
||||||
<div id="j-main-container" class="j-main-container">
|
|
||||||
<?php if (empty($this->items)) : ?>
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<span class="icon-info-circle" aria-hidden="true"></span>
|
|
||||||
<?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?>
|
|
||||||
</div>
|
|
||||||
<?php else : ?>
|
|
||||||
<table class="table" id="categoryList">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<td class="w-1 text-center"><?php echo HTMLHelper::_('grid.checkall'); ?></td>
|
|
||||||
<th scope="col"><?php echo Text::_('JGLOBAL_TITLE'); ?></th>
|
|
||||||
<th scope="col" class="w-10"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_COLOR'); ?></th>
|
|
||||||
<th scope="col" class="w-10 d-none d-md-table-cell"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_PARENT'); ?></th>
|
|
||||||
<th scope="col" class="w-10 d-none d-md-table-cell"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?></th>
|
|
||||||
<th scope="col" class="w-5 text-center"><?php echo Text::_('JSTATUS'); ?></th>
|
|
||||||
<th scope="col" class="w-5 text-center"><?php echo Text::_('JGRID_HEADING_ID'); ?></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($this->items as $i => $item) : ?>
|
|
||||||
<tr class="row<?php echo $i % 2; ?>">
|
|
||||||
<td class="text-center">
|
|
||||||
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
|
|
||||||
</td>
|
|
||||||
<th scope="row">
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=category.edit&id=' . $item->id); ?>">
|
|
||||||
<?php echo $this->escape($item->title); ?>
|
|
||||||
</a>
|
|
||||||
<?php if ($item->marker_icon) : ?>
|
|
||||||
<span class="icon-image ms-1" title="Has custom marker"></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</th>
|
|
||||||
<td>
|
|
||||||
<span style="display:inline-block;width:24px;height:24px;border-radius:4px;background:<?php echo $this->escape($item->color); ?>;vertical-align:middle;"></span>
|
|
||||||
<code class="ms-1"><?php echo $this->escape($item->color); ?></code>
|
|
||||||
</td>
|
|
||||||
<td class="d-none d-md-table-cell">
|
|
||||||
<?php echo $item->parent_title ? $this->escape($item->parent_title) : '—'; ?>
|
|
||||||
</td>
|
|
||||||
<td class="d-none d-md-table-cell text-center">
|
|
||||||
<?php echo (int) $item->location_count; ?>
|
|
||||||
</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'categories.', true, 'cb'); ?>
|
|
||||||
</td>
|
|
||||||
<td class="text-center"><?php echo (int) $item->id; ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php echo $this->pagination->getListFooter(); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<input type="hidden" name="task" value="">
|
|
||||||
<input type="hidden" name="boxchecked" value="0">
|
|
||||||
<?php echo HTMLHelper::_('form.token'); ?>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\HTML\HTMLHelper;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
|
|
||||||
HTMLHelper::_('behavior.formvalidator');
|
|
||||||
|
|
||||||
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Category\HtmlView $this */
|
|
||||||
?>
|
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&layout=edit&id=' . (int) $this->item->id); ?>"
|
|
||||||
method="post" name="adminForm" id="adminForm" class="form-validate">
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.startTabSet', 'myTab', ['active' => 'details', 'recall' => true]); ?>
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'details', 'Details'); ?>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-9">
|
|
||||||
<?php echo $this->form->renderField('title'); ?>
|
|
||||||
<?php echo $this->form->renderField('alias'); ?>
|
|
||||||
<?php echo $this->form->renderField('parent_id'); ?>
|
|
||||||
<?php echo $this->form->renderField('description'); ?>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3">
|
|
||||||
<?php echo $this->form->renderField('published'); ?>
|
|
||||||
<?php echo $this->form->renderField('id'); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'appearance', 'Appearance & Marker'); ?>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<?php echo $this->form->renderField('color'); ?>
|
|
||||||
<?php echo $this->form->renderField('marker_icon'); ?>
|
|
||||||
<?php echo $this->form->renderField('image'); ?>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5>Marker Preview</h5>
|
|
||||||
<p class="text-muted">The color swatch shows how this category's markers will appear on the map. Upload a custom SVG/PNG marker icon to override the default pin.</p>
|
|
||||||
<div id="marker-preview" style="width:40px;height:40px;border-radius:50%;background:var(--preview-color, #3b82f6);margin:1em auto;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var colorField = document.getElementById('jform_color');
|
|
||||||
var preview = document.getElementById('marker-preview');
|
|
||||||
if (colorField && preview) {
|
|
||||||
preview.style.background = colorField.value || '#3b82f6';
|
|
||||||
colorField.addEventListener('input', function() {
|
|
||||||
preview.style.background = this.value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
|
|
||||||
|
|
||||||
<input type="hidden" name="task" value="">
|
|
||||||
<?php echo HTMLHelper::_('form.token'); ?>
|
|
||||||
</form>
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\HTML\HTMLHelper;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
use Joomla\CMS\Session\Session;
|
|
||||||
|
|
||||||
$locationFields = [
|
|
||||||
'' => '— Skip —',
|
|
||||||
'title' => 'Title *',
|
|
||||||
'address' => 'Address',
|
|
||||||
'city' => 'City',
|
|
||||||
'state' => 'State',
|
|
||||||
'postcode' => 'Postal Code',
|
|
||||||
'country' => 'Country',
|
|
||||||
'latitude' => 'Latitude',
|
|
||||||
'longitude' => 'Longitude',
|
|
||||||
'phone' => 'Phone',
|
|
||||||
'email' => 'Email',
|
|
||||||
'website' => 'Website',
|
|
||||||
'hours' => 'Hours',
|
|
||||||
'description' => 'Description',
|
|
||||||
'video_url' => 'Video URL',
|
|
||||||
];
|
|
||||||
?>
|
|
||||||
<div id="import-step1">
|
|
||||||
<form id="importUploadForm" enctype="multipart/form-data">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_TITLE'); ?></h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>Step 1: Upload your CSV file. Step 2: Map columns. Step 3: Preview and import.</p>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="import_file" class="form-label"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE'); ?></label>
|
|
||||||
<input type="file" class="form-control" id="import_file" name="import_file" accept=".csv" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">CSV Delimiter</label>
|
|
||||||
<select id="csv_delimiter" class="form-select" style="width:auto;">
|
|
||||||
<option value="," selected>Comma (,)</option>
|
|
||||||
<option value=";">Semicolon (;)</option>
|
|
||||||
<option value=" ">Tab</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="geocode" name="geocode" value="1" />
|
|
||||||
<label class="form-check-label" for="geocode"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_GEOCODE'); ?></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="update_existing" name="update_existing" value="1" />
|
|
||||||
<label class="form-check-label" for="update_existing"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_UPDATE_EXISTING'); ?></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" id="parseBtn" class="btn btn-outline-primary">
|
|
||||||
<span class="icon-arrow-right" aria-hidden="true"></span> Parse & Map Columns
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FORMAT'); ?></h4></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>Supports CSV with any column order. Map your columns in step 2.</p>
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=sampledata.download&' . Session::getFormToken() . '=1'); ?>"
|
|
||||||
class="btn btn-sm btn-outline-secondary">
|
|
||||||
<span class="icon-download" aria-hidden="true"></span>
|
|
||||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_DOWNLOAD_TEMPLATE'); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="import-step2" style="display:none;">
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><h3 class="card-title">Step 2: Map Columns</h3></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>Match each CSV column to a location field. Columns that don't match will be skipped.</p>
|
|
||||||
<table class="table table-sm" id="columnMapTable">
|
|
||||||
<thead><tr><th>CSV Column</th><th>Sample Data</th><th>Maps To</th></tr></thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
<button type="button" id="previewBtn" class="btn btn-outline-primary">
|
|
||||||
<span class="icon-eye" aria-hidden="true"></span> Preview Import
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary ms-2" onclick="document.getElementById('import-step1').style.display='';document.getElementById('import-step2').style.display='none';">
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="import-step3" style="display:none;">
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header"><h3 class="card-title">Step 3: Preview & Import</h3></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="previewSummary" class="alert alert-info mb-3"></div>
|
|
||||||
<div style="max-height:400px;overflow:auto;">
|
|
||||||
<table class="table table-sm table-striped" id="previewTable">
|
|
||||||
<thead></thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=import.execute'); ?>"
|
|
||||||
method="post" id="importExecuteForm" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="import_file" id="importFileHidden" />
|
|
||||||
<input type="hidden" name="column_map" id="columnMapHidden" />
|
|
||||||
<input type="hidden" name="geocode" id="geocodeHidden" value="0" />
|
|
||||||
<input type="hidden" name="update_existing" id="updateHidden" value="0" />
|
|
||||||
<?php echo HTMLHelper::_('form.token'); ?>
|
|
||||||
<button type="submit" class="btn btn-primary mt-3">
|
|
||||||
<span class="icon-upload" aria-hidden="true"></span>
|
|
||||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_SUBMIT'); ?>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary ms-2 mt-3" onclick="document.getElementById('import-step2').style.display='';document.getElementById('import-step3').style.display='none';">
|
|
||||||
Back to Mapping
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var parsedHeaders = [];
|
|
||||||
var parsedRows = [];
|
|
||||||
var fieldOptions = <?php echo json_encode($locationFields); ?>;
|
|
||||||
|
|
||||||
// Auto-detect common column name mappings
|
|
||||||
var autoMap = {
|
|
||||||
'name': 'title', 'store': 'title', 'location': 'title', 'title': 'title',
|
|
||||||
'address': 'address', 'street': 'address', 'addr': 'address',
|
|
||||||
'city': 'city', 'town': 'city',
|
|
||||||
'state': 'state', 'province': 'state', 'region': 'state',
|
|
||||||
'zip': 'postcode', 'zipcode': 'postcode', 'postal': 'postcode', 'postcode': 'postcode',
|
|
||||||
'country': 'country',
|
|
||||||
'lat': 'latitude', 'latitude': 'latitude',
|
|
||||||
'lng': 'longitude', 'lon': 'longitude', 'longitude': 'longitude',
|
|
||||||
'phone': 'phone', 'tel': 'phone', 'telephone': 'phone',
|
|
||||||
'email': 'email', 'mail': 'email',
|
|
||||||
'website': 'website', 'url': 'website', 'web': 'website',
|
|
||||||
'hours': 'hours', 'opening': 'hours',
|
|
||||||
'description': 'description', 'desc': 'description', 'about': 'description',
|
|
||||||
'video': 'video_url'
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('parseBtn').addEventListener('click', function() {
|
|
||||||
var fileInput = document.getElementById('import_file');
|
|
||||||
if (!fileInput.files.length) { alert('Please select a CSV file.'); return; }
|
|
||||||
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onload = function(e) {
|
|
||||||
var delimiter = document.getElementById('csv_delimiter').value;
|
|
||||||
var lines = e.target.result.split('\n').filter(function(l) { return l.trim(); });
|
|
||||||
if (lines.length < 2) { alert('CSV must have a header row and at least one data row.'); return; }
|
|
||||||
|
|
||||||
parsedHeaders = parseCSVLine(lines[0], delimiter);
|
|
||||||
parsedRows = [];
|
|
||||||
for (var i = 1; i < Math.min(lines.length, 11); i++) {
|
|
||||||
parsedRows.push(parseCSVLine(lines[i], delimiter));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build column mapping table
|
|
||||||
var tbody = document.querySelector('#columnMapTable tbody');
|
|
||||||
tbody.textContent = '';
|
|
||||||
parsedHeaders.forEach(function(header, idx) {
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
var tdHeader = document.createElement('td');
|
|
||||||
tdHeader.textContent = header;
|
|
||||||
var tdSample = document.createElement('td');
|
|
||||||
tdSample.textContent = (parsedRows[0] && parsedRows[0][idx]) || '';
|
|
||||||
tdSample.style.color = '#6b7280';
|
|
||||||
var tdSelect = document.createElement('td');
|
|
||||||
var select = document.createElement('select');
|
|
||||||
select.className = 'form-select form-select-sm column-map-select';
|
|
||||||
select.dataset.index = idx;
|
|
||||||
|
|
||||||
Object.keys(fieldOptions).forEach(function(val) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = val;
|
|
||||||
opt.textContent = fieldOptions[val];
|
|
||||||
// Auto-detect
|
|
||||||
var norm = header.toLowerCase().replace(/[^a-z]/g, '');
|
|
||||||
if (autoMap[norm] === val) opt.selected = true;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
|
|
||||||
tdSelect.appendChild(select);
|
|
||||||
tr.appendChild(tdHeader);
|
|
||||||
tr.appendChild(tdSample);
|
|
||||||
tr.appendChild(tdSelect);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('import-step1').style.display = 'none';
|
|
||||||
document.getElementById('import-step2').style.display = '';
|
|
||||||
};
|
|
||||||
reader.readAsText(fileInput.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('previewBtn').addEventListener('click', function() {
|
|
||||||
var mapping = {};
|
|
||||||
document.querySelectorAll('.column-map-select').forEach(function(sel) {
|
|
||||||
if (sel.value) mapping[sel.dataset.index] = sel.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
var mappedFields = Object.values(mapping);
|
|
||||||
if (!mappedFields.includes('title')) { alert('You must map at least the Title column.'); return; }
|
|
||||||
|
|
||||||
// Build preview table
|
|
||||||
var thead = document.querySelector('#previewTable thead');
|
|
||||||
var tbody = document.querySelector('#previewTable tbody');
|
|
||||||
thead.textContent = '';
|
|
||||||
tbody.textContent = '';
|
|
||||||
|
|
||||||
var headerRow = document.createElement('tr');
|
|
||||||
mappedFields.forEach(function(f) {
|
|
||||||
var th = document.createElement('th');
|
|
||||||
th.textContent = fieldOptions[f] || f;
|
|
||||||
headerRow.appendChild(th);
|
|
||||||
});
|
|
||||||
thead.appendChild(headerRow);
|
|
||||||
|
|
||||||
var valid = 0, warnings = 0;
|
|
||||||
parsedRows.forEach(function(row) {
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
var hasTitle = false;
|
|
||||||
Object.keys(mapping).forEach(function(idx) {
|
|
||||||
var td = document.createElement('td');
|
|
||||||
var val = row[parseInt(idx)] || '';
|
|
||||||
td.textContent = val;
|
|
||||||
if (mapping[idx] === 'title' && val) hasTitle = true;
|
|
||||||
if (mapping[idx] === 'title' && !val) { td.style.background = '#fee2e2'; }
|
|
||||||
tr.appendChild(td);
|
|
||||||
});
|
|
||||||
if (hasTitle) valid++; else warnings++;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
|
||||||
|
|
||||||
var summary = document.getElementById('previewSummary');
|
|
||||||
summary.textContent = 'Showing first ' + parsedRows.length + ' rows. ' + valid + ' valid, ' + warnings + ' with issues.';
|
|
||||||
|
|
||||||
document.getElementById('columnMapHidden').value = JSON.stringify(mapping);
|
|
||||||
document.getElementById('geocodeHidden').value = document.getElementById('geocode').checked ? '1' : '0';
|
|
||||||
document.getElementById('updateHidden').value = document.getElementById('update_existing').checked ? '1' : '0';
|
|
||||||
|
|
||||||
document.getElementById('import-step2').style.display = 'none';
|
|
||||||
document.getElementById('import-step3').style.display = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reattach file to the execute form
|
|
||||||
document.getElementById('importExecuteForm').addEventListener('submit', function(e) {
|
|
||||||
var fileInput = document.getElementById('import_file');
|
|
||||||
if (fileInput.files.length) {
|
|
||||||
var clone = fileInput.cloneNode(true);
|
|
||||||
clone.style.display = 'none';
|
|
||||||
this.appendChild(clone);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function parseCSVLine(line, delimiter) {
|
|
||||||
var result = [];
|
|
||||||
var current = '';
|
|
||||||
var inQuotes = false;
|
|
||||||
for (var i = 0; i < line.length; i++) {
|
|
||||||
var c = line[i];
|
|
||||||
if (c === '"') { inQuotes = !inQuotes; }
|
|
||||||
else if (c === delimiter && !inQuotes) { result.push(current.trim()); current = ''; }
|
|
||||||
else { current += c; }
|
|
||||||
}
|
|
||||||
result.push(current.trim());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\HTML\HTMLHelper;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Layout\LayoutHelper;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
|
|
||||||
/** @var \Moko\Component\MokoJoomStoreLocator\Administrator\View\Location\HtmlView $this */
|
|
||||||
|
|
||||||
HTMLHelper::_('behavior.formvalidator');
|
|
||||||
HTMLHelper::_('behavior.keepalive');
|
|
||||||
|
|
||||||
$wa = $this->getDocument()->getWebAssetManager();
|
|
||||||
$wa->useScript('keepalive')
|
|
||||||
->useScript('form.validate');
|
|
||||||
?>
|
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&layout=edit&id=' . (int) $this->item->id); ?>"
|
|
||||||
method="post" name="adminForm" id="adminForm" class="form-validate">
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.startTabSet', 'myTab', ['active' => 'details', 'recall' => true, 'breakpoint' => 768]); ?>
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'details', Text::_('JDETAILS')); ?>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-9">
|
|
||||||
<?php echo $this->form->renderField('title'); ?>
|
|
||||||
<?php echo $this->form->renderField('alias'); ?>
|
|
||||||
<?php echo $this->form->renderField('description'); ?>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3">
|
|
||||||
<?php echo $this->form->renderField('published'); ?>
|
|
||||||
<?php echo $this->form->renderField('id'); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'address', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS')); ?>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<?php echo $this->form->renderField('address'); ?>
|
|
||||||
<?php echo $this->form->renderField('city'); ?>
|
|
||||||
<?php echo $this->form->renderField('state'); ?>
|
|
||||||
<?php echo $this->form->renderField('postcode'); ?>
|
|
||||||
<?php echo $this->form->renderField('country'); ?>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_COORDINATES'); ?></h4>
|
|
||||||
<small class="text-muted">Click the map to set coordinates</small>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="coordinate-picker-map" style="height: 300px; border: 1px solid #dee2e6; border-radius: 4px;"></div>
|
|
||||||
<div class="row mt-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<?php echo $this->form->renderField('latitude'); ?>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<?php echo $this->form->renderField('longitude'); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'contact', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT')); ?>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<?php echo $this->form->renderField('phone'); ?>
|
|
||||||
<?php echo $this->form->renderField('email'); ?>
|
|
||||||
<?php echo $this->form->renderField('website'); ?>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<?php echo $this->form->renderField('hours'); ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.addTab', 'myTab', 'image', Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_IMAGE')); ?>
|
|
||||||
<?php echo $this->form->renderField('image'); ?>
|
|
||||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
|
||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.endTabSet'); ?>
|
|
||||||
|
|
||||||
<input type="hidden" name="task" value="">
|
|
||||||
<?php echo HTMLHelper::_('form.token'); ?>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var latField = document.getElementById('jform_latitude');
|
|
||||||
var lngField = document.getElementById('jform_longitude');
|
|
||||||
var lat = parseFloat(latField.value) || 39.8283;
|
|
||||||
var lng = parseFloat(lngField.value) || -98.5795;
|
|
||||||
var zoom = (latField.value && lngField.value) ? 15 : 4;
|
|
||||||
|
|
||||||
var map = L.map('coordinate-picker-map').setView([lat, lng], zoom);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
maxZoom: 19
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
var marker = null;
|
|
||||||
|
|
||||||
if (latField.value && lngField.value) {
|
|
||||||
marker = L.marker([lat, lng]).addTo(map);
|
|
||||||
}
|
|
||||||
|
|
||||||
map.on('click', function(e) {
|
|
||||||
var clickLat = e.latlng.lat.toFixed(8);
|
|
||||||
var clickLng = e.latlng.lng.toFixed(8);
|
|
||||||
|
|
||||||
latField.value = clickLat;
|
|
||||||
lngField.value = clickLng;
|
|
||||||
|
|
||||||
if (marker) {
|
|
||||||
marker.setLatLng(e.latlng);
|
|
||||||
} else {
|
|
||||||
marker = L.marker(e.latlng).addTo(map);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update marker when fields change manually
|
|
||||||
function updateMarkerFromFields() {
|
|
||||||
var newLat = parseFloat(latField.value);
|
|
||||||
var newLng = parseFloat(lngField.value);
|
|
||||||
if (!isNaN(newLat) && !isNaN(newLng)) {
|
|
||||||
var latlng = L.latLng(newLat, newLng);
|
|
||||||
if (marker) {
|
|
||||||
marker.setLatLng(latlng);
|
|
||||||
} else {
|
|
||||||
marker = L.marker(latlng).addTo(map);
|
|
||||||
}
|
|
||||||
map.setView(latlng, 15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
latField.addEventListener('change', updateMarkerFromFields);
|
|
||||||
lngField.addEventListener('change', updateMarkerFromFields);
|
|
||||||
|
|
||||||
// Fix map rendering in tabs
|
|
||||||
document.querySelector('[data-bs-target="#address"]')?.addEventListener('shown.bs.tab', function() {
|
|
||||||
map.invalidateSize();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -54,31 +54,7 @@ use Joomla\CMS\Router\Route;
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($this->items as $i => $item) : ?>
|
<!-- TODO: Render location rows -->
|
||||||
<tr class="row<?php echo $i % 2; ?>">
|
|
||||||
<td class="text-center">
|
|
||||||
<?php echo HTMLHelper::_('grid.id', $i, $item->id, false, 'cid', 'cb', $item->title); ?>
|
|
||||||
</td>
|
|
||||||
<th scope="row">
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=location.edit&id=' . $item->id); ?>">
|
|
||||||
<?php echo $this->escape($item->title); ?>
|
|
||||||
</a>
|
|
||||||
<div class="small"><?php echo Text::_('JFIELD_ALIAS_LABEL') . ': ' . $this->escape($item->alias); ?></div>
|
|
||||||
</th>
|
|
||||||
<td class="d-none d-md-table-cell">
|
|
||||||
<?php echo $this->escape($item->city); ?>
|
|
||||||
</td>
|
|
||||||
<td class="d-none d-md-table-cell">
|
|
||||||
<?php echo $this->escape($item->state); ?>
|
|
||||||
</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'locations.', true, 'cb'); ?>
|
|
||||||
</td>
|
|
||||||
<td class="text-center">
|
|
||||||
<?php echo (int) $item->id; ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,15 @@
|
|||||||
=========================================================================
|
=========================================================================
|
||||||
-->
|
-->
|
||||||
<extension type="component" method="upgrade">
|
<extension type="component" method="upgrade">
|
||||||
<name>Moko Store Locator</name>
|
<name>com_mokojoomstorelocator</name>
|
||||||
<version>00.00.01</version>
|
<version>1.0.0</version>
|
||||||
<creationDate>2026-05-22</creationDate>
|
<creationDate>2026-05-21</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||||
<description>A store locator component for managing and displaying location listings.</description>
|
<description>COM_MOKOJOOMSTORELOCATOR_DESC</description>
|
||||||
|
|
||||||
<namespace path="src">Moko\Component\MokoJoomStoreLocator</namespace>
|
<namespace path="src">Moko\Component\MokoJoomStoreLocator</namespace>
|
||||||
|
|
||||||
@@ -37,14 +37,8 @@
|
|||||||
<file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file>
|
<file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file>
|
||||||
</sql>
|
</sql>
|
||||||
</uninstall>
|
</uninstall>
|
||||||
n <update>
|
|
||||||
<schemas>
|
|
||||||
<schemapath type="mysql">sql/updates/mysql</schemapath>
|
|
||||||
</schemas>
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<files folder="site">
|
<files folder="site">
|
||||||
<folder>css</folder>
|
|
||||||
<folder>language</folder>
|
<folder>language</folder>
|
||||||
<folder>src</folder>
|
<folder>src</folder>
|
||||||
<folder>tmpl</folder>
|
<folder>tmpl</folder>
|
||||||
@@ -53,8 +47,7 @@ n <update>
|
|||||||
<administration>
|
<administration>
|
||||||
<files folder="admin">
|
<files folder="admin">
|
||||||
<folder>forms</folder>
|
<folder>forms</folder>
|
||||||
<folder>css</folder>
|
<folder>language</folder>
|
||||||
<folder>language</folder>
|
|
||||||
<folder>services</folder>
|
<folder>services</folder>
|
||||||
<folder>sql</folder>
|
<folder>sql</folder>
|
||||||
<folder>src</folder>
|
<folder>src</folder>
|
||||||
@@ -64,44 +57,6 @@ n <update>
|
|||||||
<menu>COM_MOKOJOOMSTORELOCATOR</menu>
|
<menu>COM_MOKOJOOMSTORELOCATOR</menu>
|
||||||
<submenu>
|
<submenu>
|
||||||
<menu link="option=com_mokojoomstorelocator&view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
|
<menu link="option=com_mokojoomstorelocator&view=locations">COM_MOKOJOOMSTORELOCATOR_LOCATIONS</menu>
|
||||||
<menu link="option=com_mokojoomstorelocator&view=categories">COM_MOKOJOOMSTORELOCATOR_CATEGORIES</menu>
|
|
||||||
<menu link="option=com_mokojoomstorelocator&view=import">COM_MOKOJOOMSTORELOCATOR_IMPORT</menu>
|
|
||||||
</submenu>
|
</submenu>
|
||||||
</administration>
|
</administration>
|
||||||
|
|
||||||
<config>
|
|
||||||
<fields name="params">
|
|
||||||
<fieldset name="geocoding" label="COM_MOKOJOOMSTORELOCATOR_FIELDSET_GEOCODING">
|
|
||||||
<field
|
|
||||||
name="geocoder_provider"
|
|
||||||
type="list"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_GEOCODER_PROVIDER"
|
|
||||||
default="nominatim"
|
|
||||||
>
|
|
||||||
<option value="nominatim">OpenStreetMap (Nominatim) — Free</option>
|
|
||||||
<option value="google">Google Geocoding API</option>
|
|
||||||
</field>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="google_api_key"
|
|
||||||
type="text"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY"
|
|
||||||
description="COM_MOKOJOOMSTORELOCATOR_FIELD_GOOGLE_API_KEY_DESC"
|
|
||||||
showon="geocoder_provider:google"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<field
|
|
||||||
name="auto_geocode"
|
|
||||||
type="radio"
|
|
||||||
label="COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE"
|
|
||||||
description="COM_MOKOJOOMSTORELOCATOR_FIELD_AUTO_GEOCODE_DESC"
|
|
||||||
default="1"
|
|
||||||
class="btn-group"
|
|
||||||
>
|
|
||||||
<option value="1">JYES</option>
|
|
||||||
<option value="0">JNO</option>
|
|
||||||
</field>
|
|
||||||
</fieldset>
|
|
||||||
</fields>
|
|
||||||
</config>
|
|
||||||
</extension>
|
</extension>
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
/* MokoJoomStoreLocator — Responsive site styles
|
|
||||||
* Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* License: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* === Location list === */
|
|
||||||
.mokojoomstorelocator-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-location {
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-location:hover {
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-location h3 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-location h3 a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-address,
|
|
||||||
.mokojoomstorelocator-phone,
|
|
||||||
.mokojoomstorelocator-website,
|
|
||||||
.mokojoomstorelocator-hours,
|
|
||||||
.mokojoomstorelocator-distance,
|
|
||||||
.mokojoomstorelocator-directions,
|
|
||||||
.mokojoomstorelocator-categories-tags {
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-categories-tags span {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #fff;
|
|
||||||
margin-right: 4px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Image === */
|
|
||||||
.mokojoomstorelocator-image img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Gallery (multi-image) === */
|
|
||||||
.mokojoomstorelocator-gallery {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-gallery img {
|
|
||||||
width: 100%;
|
|
||||||
height: 120px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Video embed === */
|
|
||||||
.mokojoomstorelocator-video {
|
|
||||||
position: relative;
|
|
||||||
padding-bottom: 56.25%;
|
|
||||||
height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-video iframe {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Business hours === */
|
|
||||||
.mokojoomstorelocator-hours-table {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-hours-table td {
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-hours-table td:first-child {
|
|
||||||
font-weight: 600;
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-open-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-open-badge--open {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-open-badge--closed {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Responsive === */
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.mokojoomstorelocator-list {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.com-mokojoomstorelocator-location .row {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.com-mokojoomstorelocator-location .col-lg-5,
|
|
||||||
.com-mokojoomstorelocator-location .col-lg-7 {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Click-to-call on mobile */
|
|
||||||
a[href^="tel:"] {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 6px 16px;
|
|
||||||
background: #3b82f6;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 6px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Print === */
|
|
||||||
@media print {
|
|
||||||
.mokojoomstorelocator-directions,
|
|
||||||
.mod-mokojoomstorelocator-search,
|
|
||||||
.mod-mokojoomstorelocator-map,
|
|
||||||
.mokojoomstorelocator-video,
|
|
||||||
.btn,
|
|
||||||
nav,
|
|
||||||
footer {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-location {
|
|
||||||
break-inside: avoid;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
padding: 0.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.com-mokojoomstorelocator-location {
|
|
||||||
font-size: 12pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.com-mokojoomstorelocator-location .col-lg-5 {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-print-map {
|
|
||||||
display: block !important;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-print-btn {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mokojoomstorelocator-print-map {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
|
|
||||||
"name": "com_mokojoomstorelocator",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Web assets for MokoJoomStoreLocator",
|
|
||||||
"license": "GPL-3.0-or-later",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"name": "com_mokojoomstorelocator.site",
|
|
||||||
"type": "style",
|
|
||||||
"uri": "components/com_mokojoomstorelocator/css/storelocator.css"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "com_mokojoomstorelocator.leaflet",
|
|
||||||
"type": "script",
|
|
||||||
"uri": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js",
|
|
||||||
"attributes": { "crossorigin": "" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "com_mokojoomstorelocator.leaflet.css",
|
|
||||||
"type": "style",
|
|
||||||
"uri": "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css",
|
|
||||||
"attributes": { "crossorigin": "" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "com_mokojoomstorelocator.markercluster",
|
|
||||||
"type": "script",
|
|
||||||
"uri": "https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js",
|
|
||||||
"dependencies": ["com_mokojoomstorelocator.leaflet"],
|
|
||||||
"attributes": { "crossorigin": "" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "com_mokojoomstorelocator.markercluster.css",
|
|
||||||
"type": "style",
|
|
||||||
"uri": "https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css",
|
|
||||||
"attributes": { "crossorigin": "" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
; MokoJoomStoreLocator - Site language strings
|
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
; License: GNU General Public License version 3 or later; see LICENSE
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR="Store Locator"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Store Locations"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_DETAIL="Location Detail"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS="Back to All Locations"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_PRINT="Print"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO="Additional Information"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORY="Category"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_TITLE="Contact This Location"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_NAME="Your Name"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_EMAIL="Your Email"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_SUBJECT="Subject"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_MESSAGE="Message"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND="Send Message"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_SENT="Your message has been sent."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND_FAILED="Unable to send your message. Please try again later."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_FIELDS_REQUIRED="Please fill in all required fields."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_INVALID_EMAIL="Please enter a valid email address."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_NO_RECIPIENT="This location does not accept messages."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_CAPTCHA_FAILED="Captcha verification failed."
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
; MokoJoomStoreLocator - Site language strings
|
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
; License: GNU General Public License version 3 or later; see LICENSE
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR="Store Locator"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_LOCATIONS="Store Locations"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_NO_LOCATIONS="No locations found."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_LOCATION_DETAIL="Location Detail"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS="Get Directions"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS="Back to All Locations"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS="Address"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT="Contact Information"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE="Phone"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE="Website"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS="Business Hours"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_PRINT="Print"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO="Additional Information"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORY="Category"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CATEGORIES="Categories"
|
|
||||||
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_TITLE="Contact This Location"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_NAME="Your Name"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_EMAIL="Your Email"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_SUBJECT="Subject"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_MESSAGE="Message"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND="Send Message"
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_SENT="Your message has been sent."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND_FAILED="Unable to send your message. Please try again later."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_FIELDS_REQUIRED="Please fill in all required fields."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_INVALID_EMAIL="Please enter a valid email address."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_NO_RECIPIENT="This location does not accept messages."
|
|
||||||
COM_MOKOJOOMSTORELOCATOR_CONTACT_CAPTCHA_FAILED="Captcha verification failed."
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
// Site service provider delegates to admin provider
|
|
||||||
// The admin services/provider.php handles component registration for both sides
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Site\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Mail\MailerFactoryInterface;
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
use Joomla\CMS\Session\Session;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
use Joomla\Database\ParameterType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Contact form controller — sends email to a location's configured email address.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class ContactController extends BaseController
|
|
||||||
{
|
|
||||||
public function send(): void
|
|
||||||
{
|
|
||||||
Session::checkToken() or die(Text::_('JINVALID_TOKEN'));
|
|
||||||
|
|
||||||
$app = Factory::getApplication();
|
|
||||||
$input = $app->getInput();
|
|
||||||
|
|
||||||
$locationId = $input->getInt('location_id', 0);
|
|
||||||
$senderName = $input->getString('contact_name', '');
|
|
||||||
$senderEmail = $input->getString('contact_email', '');
|
|
||||||
$subject = $input->getString('contact_subject', '');
|
|
||||||
$message = $input->getString('contact_message', '');
|
|
||||||
|
|
||||||
// Validate captcha if configured
|
|
||||||
$captchaPlugin = $app->get('captcha', '');
|
|
||||||
|
|
||||||
if ($captchaPlugin && $captchaPlugin !== '0')
|
|
||||||
{
|
|
||||||
$captcha = \Joomla\CMS\Captcha\Captcha::getInstance($captchaPlugin);
|
|
||||||
|
|
||||||
if (!$captcha->checkAnswer(''))
|
|
||||||
{
|
|
||||||
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_CAPTCHA_FAILED'), 'error');
|
|
||||||
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (empty($senderName) || empty($senderEmail) || empty($message) || !$locationId)
|
|
||||||
{
|
|
||||||
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_FIELDS_REQUIRED'), 'error');
|
|
||||||
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filter_var($senderEmail, FILTER_VALIDATE_EMAIL))
|
|
||||||
{
|
|
||||||
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_INVALID_EMAIL'), 'error');
|
|
||||||
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load location to get recipient email
|
|
||||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select([$db->quoteName('title'), $db->quoteName('email')])
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_locations'))
|
|
||||||
->where($db->quoteName('id') . ' = :id')
|
|
||||||
->bind(':id', $locationId, ParameterType::INTEGER);
|
|
||||||
$db->setQuery($query);
|
|
||||||
$location = $db->loadObject();
|
|
||||||
|
|
||||||
if (!$location || empty($location->email))
|
|
||||||
{
|
|
||||||
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_NO_RECIPIENT'), 'error');
|
|
||||||
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email
|
|
||||||
try
|
|
||||||
{
|
|
||||||
$mailer = Factory::getContainer()->get(MailerFactoryInterface::class)->createMailer();
|
|
||||||
$mailer->addRecipient($location->email);
|
|
||||||
$mailer->addReplyTo($senderEmail, $senderName);
|
|
||||||
$mailer->setSubject('[Store Locator] ' . ($subject ?: 'Contact from ' . $senderName));
|
|
||||||
$mailer->setBody(
|
|
||||||
"Name: $senderName\n"
|
|
||||||
. "Email: $senderEmail\n"
|
|
||||||
. "Location: {$location->title}\n\n"
|
|
||||||
. "Message:\n$message"
|
|
||||||
);
|
|
||||||
|
|
||||||
$mailer->Send();
|
|
||||||
|
|
||||||
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SENT'), 'success');
|
|
||||||
}
|
|
||||||
catch (\Exception $e)
|
|
||||||
{
|
|
||||||
$app->enqueueMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND_FAILED'), 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
$app->redirect(Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $locationId, false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Site\Controller;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default controller for the site side of the component.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class DisplayController extends BaseController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The default view.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected $default_view = 'locations';
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Site\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\ListModel;
|
|
||||||
use Joomla\Database\ParameterType;
|
|
||||||
use Joomla\Database\QueryInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Category model — loads locations belonging to a specific category.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class CategoryModel extends ListModel
|
|
||||||
{
|
|
||||||
protected function getListQuery(): QueryInterface
|
|
||||||
{
|
|
||||||
$db = $this->getDatabase();
|
|
||||||
$query = $db->getQuery(true);
|
|
||||||
$catId = (int) $this->getState('category.id');
|
|
||||||
|
|
||||||
$query->select('a.*')
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'))
|
|
||||||
->join('INNER', $db->quoteName('#__mokojoomstorelocator_location_categories', 'lc')
|
|
||||||
. ' ON lc.location_id = a.id')
|
|
||||||
->where($db->quoteName('a.published') . ' = 1')
|
|
||||||
->where($db->quoteName('lc.category_id') . ' = :catid')
|
|
||||||
->bind(':catid', $catId, ParameterType::INTEGER)
|
|
||||||
->order($db->quoteName('a.ordering') . ' ASC');
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the category record.
|
|
||||||
*
|
|
||||||
* @return object|null
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function getCategory(): ?object
|
|
||||||
{
|
|
||||||
$catId = (int) $this->getState('category.id');
|
|
||||||
|
|
||||||
if (!$catId)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = $this->getDatabase();
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select('*')
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_categories'))
|
|
||||||
->where($db->quoteName('id') . ' = :id')
|
|
||||||
->where($db->quoteName('published') . ' = 1')
|
|
||||||
->bind(':id', $catId, ParameterType::INTEGER);
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
|
|
||||||
return $db->loadObject();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Site\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\ItemModel;
|
|
||||||
use Joomla\Database\ParameterType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single location model for the frontend detail view.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class LocationModel extends ItemModel
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get a single location by ID.
|
|
||||||
*
|
|
||||||
* @param int $pk The location ID.
|
|
||||||
*
|
|
||||||
* @return object|false The location record or false.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function getItem($pk = null)
|
|
||||||
{
|
|
||||||
$pk = $pk ?: (int) $this->getState('location.id');
|
|
||||||
|
|
||||||
if (!$pk)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$db = $this->getDatabase();
|
|
||||||
$query = $db->getQuery(true);
|
|
||||||
|
|
||||||
$query->select('a.*')
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'))
|
|
||||||
->where($db->quoteName('a.id') . ' = :id')
|
|
||||||
->where($db->quoteName('a.published') . ' = 1')
|
|
||||||
->bind(':id', $pk, ParameterType::INTEGER);
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
|
|
||||||
return $db->loadObject();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Site\Model;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\Model\ListModel;
|
|
||||||
use Joomla\Database\QueryInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locations list model for the site (frontend) view.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class LocationsModel extends ListModel
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Build an SQL query to load the list data.
|
|
||||||
*
|
|
||||||
* @return QueryInterface
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected function getListQuery(): QueryInterface
|
|
||||||
{
|
|
||||||
$db = $this->getDatabase();
|
|
||||||
$query = $db->getQuery(true);
|
|
||||||
|
|
||||||
$query->select('a.*')
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_locations', 'a'))
|
|
||||||
->where($db->quoteName('a.published') . ' = 1');
|
|
||||||
|
|
||||||
$catId = (int) $this->getState('filter.category_id');
|
|
||||||
|
|
||||||
if ($catId > 0)
|
|
||||||
{
|
|
||||||
$query->where($db->quoteName('a.catid') . ' = :catid')
|
|
||||||
->bind(':catid', $catId, \Joomla\Database\ParameterType::INTEGER);
|
|
||||||
}
|
|
||||||
|
|
||||||
$search = $this->getState('filter.search');
|
|
||||||
|
|
||||||
if (!empty($search))
|
|
||||||
{
|
|
||||||
$search = '%' . trim($search) . '%';
|
|
||||||
$query->where(
|
|
||||||
'(' . $db->quoteName('a.title') . ' LIKE :search1'
|
|
||||||
. ' OR ' . $db->quoteName('a.city') . ' LIKE :search2'
|
|
||||||
. ' OR ' . $db->quoteName('a.address') . ' LIKE :search3'
|
|
||||||
. ' OR ' . $db->quoteName('a.postcode') . ' LIKE :search4)'
|
|
||||||
)
|
|
||||||
->bind(':search1', $search)
|
|
||||||
->bind(':search2', $search)
|
|
||||||
->bind(':search3', $search)
|
|
||||||
->bind(':search4', $search);
|
|
||||||
}
|
|
||||||
|
|
||||||
$city = $this->getState('filter.city');
|
|
||||||
|
|
||||||
if (!empty($city))
|
|
||||||
{
|
|
||||||
$query->where($db->quoteName('a.city') . ' = :city')
|
|
||||||
->bind(':city', $city);
|
|
||||||
}
|
|
||||||
|
|
||||||
$state = $this->getState('filter.state');
|
|
||||||
|
|
||||||
if (!empty($state))
|
|
||||||
{
|
|
||||||
$query->where($db->quoteName('a.state') . ' = :state')
|
|
||||||
->bind(':state', $state);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Radius search using Haversine formula (bounding box pre-filter + SQL distance)
|
|
||||||
$lat = (float) $this->getState('filter.latitude');
|
|
||||||
$lng = (float) $this->getState('filter.longitude');
|
|
||||||
$radius = (float) $this->getState('filter.radius');
|
|
||||||
$unit = $this->getState('filter.radius_unit', 'miles');
|
|
||||||
|
|
||||||
if ($lat && $lng && $radius > 0)
|
|
||||||
{
|
|
||||||
// Earth radius: 3959 miles or 6371 km
|
|
||||||
$earthRadius = ($unit === 'km') ? 6371 : 3959;
|
|
||||||
|
|
||||||
$query->where($db->quoteName('a.latitude') . ' IS NOT NULL')
|
|
||||||
->where($db->quoteName('a.longitude') . ' IS NOT NULL');
|
|
||||||
|
|
||||||
// Haversine distance calculation
|
|
||||||
$haversine = sprintf(
|
|
||||||
'(%f * ACOS(COS(RADIANS(%f)) * COS(RADIANS(a.latitude))'
|
|
||||||
. ' * COS(RADIANS(a.longitude) - RADIANS(%f))'
|
|
||||||
. ' + SIN(RADIANS(%f)) * SIN(RADIANS(a.latitude))))',
|
|
||||||
$earthRadius,
|
|
||||||
$lat,
|
|
||||||
$lng,
|
|
||||||
$lat
|
|
||||||
);
|
|
||||||
|
|
||||||
$query->select($haversine . ' AS distance')
|
|
||||||
->having('distance <= ' . (float) $radius)
|
|
||||||
->order('distance ASC');
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$query->order($db->quoteName('a.ordering') . ' ASC');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Site\Service;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Application\SiteApplication;
|
|
||||||
use Joomla\CMS\Component\Router\RouterView;
|
|
||||||
use Joomla\CMS\Component\Router\RouterViewConfiguration;
|
|
||||||
use Joomla\CMS\Component\Router\Rules\MenuRules;
|
|
||||||
use Joomla\CMS\Component\Router\Rules\NomenuRules;
|
|
||||||
use Joomla\CMS\Component\Router\Rules\StandardRules;
|
|
||||||
use Joomla\CMS\Menu\AbstractMenu;
|
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SEF URL router for com_mokojoomstorelocator.
|
|
||||||
*
|
|
||||||
* Routes:
|
|
||||||
* /store-locator → locations view
|
|
||||||
* /store-locator/category-alias → locations filtered by category
|
|
||||||
* /store-locator/location-alias → single location detail
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class Router extends RouterView
|
|
||||||
{
|
|
||||||
public function __construct(SiteApplication $app, AbstractMenu $menu)
|
|
||||||
{
|
|
||||||
$locations = new RouterViewConfiguration('locations');
|
|
||||||
$this->registerView($locations);
|
|
||||||
|
|
||||||
$category = new RouterViewConfiguration('category');
|
|
||||||
$category->setKey('id')->setParent($locations);
|
|
||||||
$this->registerView($category);
|
|
||||||
|
|
||||||
$location = new RouterViewConfiguration('location');
|
|
||||||
$location->setKey('id')->setParent($locations);
|
|
||||||
$this->registerView($location);
|
|
||||||
|
|
||||||
parent::__construct($app, $menu);
|
|
||||||
|
|
||||||
$this->attachRule(new MenuRules($this));
|
|
||||||
$this->attachRule(new StandardRules($this));
|
|
||||||
$this->attachRule(new NomenuRules($this));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLocationSegment($id, $query): array
|
|
||||||
{
|
|
||||||
return $this->getSegmentFromAlias($id, '#__mokojoomstorelocator_locations');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getLocationId($segment, $query): int|false
|
|
||||||
{
|
|
||||||
return $this->getIdFromAlias($segment, '#__mokojoomstorelocator_locations');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCategorySegment($id, $query): array
|
|
||||||
{
|
|
||||||
return $this->getSegmentFromAlias($id, '#__mokojoomstorelocator_categories');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCategoryId($segment, $query): int|false
|
|
||||||
{
|
|
||||||
return $this->getIdFromAlias($segment, '#__mokojoomstorelocator_categories');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getSegmentFromAlias($id, string $table): array
|
|
||||||
{
|
|
||||||
if (strpos($id, ':') === false)
|
|
||||||
{
|
|
||||||
$db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class);
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select($db->quoteName('alias'))
|
|
||||||
->from($db->quoteName($table))
|
|
||||||
->where($db->quoteName('id') . ' = :id')
|
|
||||||
->bind(':id', $id, \Joomla\Database\ParameterType::INTEGER);
|
|
||||||
$db->setQuery($query);
|
|
||||||
$alias = $db->loadResult();
|
|
||||||
|
|
||||||
if ($alias)
|
|
||||||
{
|
|
||||||
$id = $id . ':' . $alias;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[$numericId, $alias] = explode(':', $id, 2) + [1 => ''];
|
|
||||||
|
|
||||||
return [$numericId => $alias ?: $numericId];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getIdFromAlias($segment, string $table): int|false
|
|
||||||
{
|
|
||||||
$db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class);
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select($db->quoteName('id'))
|
|
||||||
->from($db->quoteName($table))
|
|
||||||
->where($db->quoteName('alias') . ' = :alias')
|
|
||||||
->bind(':alias', $segment);
|
|
||||||
$db->setQuery($query);
|
|
||||||
$id = $db->loadResult();
|
|
||||||
|
|
||||||
return $id ? (int) $id : (int) $segment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Site\View\Category;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Category view — shows locations filtered by a specific category.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
protected $items;
|
|
||||||
protected $category;
|
|
||||||
protected $pagination;
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->items = $this->get('Items');
|
|
||||||
$this->category = $this->get('Category');
|
|
||||||
$this->pagination = $this->get('Pagination');
|
|
||||||
|
|
||||||
if ($this->category)
|
|
||||||
{
|
|
||||||
$this->getDocument()->setTitle($this->category->title . ' — Store Locator');
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Site\View\Location;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single location detail view for the frontend.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var object The location item.
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected $item;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array Categories assigned to this location.
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected $categories = [];
|
|
||||||
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->item = $this->get('Item');
|
|
||||||
|
|
||||||
if (!$this->item)
|
|
||||||
{
|
|
||||||
throw new \Exception('Location not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load categories for this location
|
|
||||||
$this->categories = $this->loadLocationCategories((int) $this->item->id);
|
|
||||||
|
|
||||||
// Set page title and meta
|
|
||||||
$doc = $this->getDocument();
|
|
||||||
$doc->setTitle($this->item->title . ' — Store Locator');
|
|
||||||
|
|
||||||
if ($this->item->description)
|
|
||||||
{
|
|
||||||
$doc->setDescription(substr(strip_tags($this->item->description), 0, 160));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->addStructuredData();
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load categories for a location from the junction table.
|
|
||||||
*
|
|
||||||
* @param int $locationId Location ID.
|
|
||||||
*
|
|
||||||
* @return array Category objects.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
private function loadLocationCategories(int $locationId): array
|
|
||||||
{
|
|
||||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select(['c.id', 'c.title', 'c.alias', 'c.color'])
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_location_categories', 'lc'))
|
|
||||||
->join('INNER', $db->quoteName('#__mokojoomstorelocator_categories', 'c') . ' ON c.id = lc.category_id AND c.published = 1')
|
|
||||||
->where($db->quoteName('lc.location_id') . ' = :id')
|
|
||||||
->bind(':id', $locationId, \Joomla\Database\ParameterType::INTEGER);
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
|
|
||||||
return $db->loadObjectList() ?: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add Schema.org LocalBusiness JSON-LD to the document.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected function addStructuredData(): void
|
|
||||||
{
|
|
||||||
$item = $this->item;
|
|
||||||
|
|
||||||
$schema = [
|
|
||||||
'@context' => 'https://schema.org',
|
|
||||||
'@type' => 'LocalBusiness',
|
|
||||||
'name' => $item->title,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($item->description)
|
|
||||||
{
|
|
||||||
$schema['description'] = strip_tags($item->description);
|
|
||||||
}
|
|
||||||
|
|
||||||
$address = [];
|
|
||||||
|
|
||||||
if ($item->address)
|
|
||||||
{
|
|
||||||
$address['streetAddress'] = $item->address;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->city)
|
|
||||||
{
|
|
||||||
$address['addressLocality'] = $item->city;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->state)
|
|
||||||
{
|
|
||||||
$address['addressRegion'] = $item->state;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->postcode)
|
|
||||||
{
|
|
||||||
$address['postalCode'] = $item->postcode;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->country)
|
|
||||||
{
|
|
||||||
$address['addressCountry'] = $item->country;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($address))
|
|
||||||
{
|
|
||||||
$address['@type'] = 'PostalAddress';
|
|
||||||
$schema['address'] = $address;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->latitude && $item->longitude)
|
|
||||||
{
|
|
||||||
$schema['geo'] = [
|
|
||||||
'@type' => 'GeoCoordinates',
|
|
||||||
'latitude' => (float) $item->latitude,
|
|
||||||
'longitude' => (float) $item->longitude,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->phone)
|
|
||||||
{
|
|
||||||
$schema['telephone'] = $item->phone;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->email)
|
|
||||||
{
|
|
||||||
$schema['email'] = $item->email;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->website)
|
|
||||||
{
|
|
||||||
$schema['url'] = $item->website;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($item->image)
|
|
||||||
{
|
|
||||||
$schema['image'] = $item->image;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->getDocument()->addScriptOptions('com_mokojoomstorelocator.schema', $schema);
|
|
||||||
$this->getDocument()->addCustomTag(
|
|
||||||
'<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . '</script>'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Component\MokoJoomStoreLocator\Site\View\Locations;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locations list view for the frontend.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
class HtmlView extends BaseHtmlView
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The list of locations.
|
|
||||||
*
|
|
||||||
* @var array
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected $items;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The pagination object.
|
|
||||||
*
|
|
||||||
* @var \Joomla\CMS\Pagination\Pagination
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
protected $pagination;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the view.
|
|
||||||
*
|
|
||||||
* @param string $tpl The name of the template file to parse.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public function display($tpl = null): void
|
|
||||||
{
|
|
||||||
$this->items = $this->get('Items');
|
|
||||||
$this->pagination = $this->get('Pagination');
|
|
||||||
|
|
||||||
parent::display($tpl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
|
|
||||||
/** @var \Moko\Component\MokoJoomStoreLocator\Site\View\Category\HtmlView $this */
|
|
||||||
$cat = $this->category;
|
|
||||||
?>
|
|
||||||
<div class="com-mokojoomstorelocator-category">
|
|
||||||
<?php if ($cat) : ?>
|
|
||||||
<h2>
|
|
||||||
<?php if ($cat->color) : ?>
|
|
||||||
<span style="display:inline-block;width:16px;height:16px;border-radius:50%;background:<?php echo $this->escape($cat->color); ?>;vertical-align:middle;margin-right:6px;"></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php echo $this->escape($cat->title); ?>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<?php if ($cat->description) : ?>
|
|
||||||
<div class="mokojoomstorelocator-category-desc mb-3"><?php echo $cat->description; ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (empty($this->items)) : ?>
|
|
||||||
<p><?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?></p>
|
|
||||||
<?php else : ?>
|
|
||||||
<div class="mokojoomstorelocator-list">
|
|
||||||
<?php foreach ($this->items as $item) : ?>
|
|
||||||
<div class="mokojoomstorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
|
|
||||||
<h3 itemprop="name">
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $item->id . ':' . $item->alias); ?>">
|
|
||||||
<?php echo $this->escape($item->title); ?>
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
<?php if ($item->address || $item->city) : ?>
|
|
||||||
<div class="mokojoomstorelocator-address">
|
|
||||||
<?php echo $this->escape(trim($item->address . ', ' . $item->city . ', ' . $item->state . ' ' . $item->postcode, ', ')); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($item->phone) : ?>
|
|
||||||
<div><a href="tel:<?php echo $this->escape($item->phone); ?>"><?php echo $this->escape($item->phone); ?></a></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php echo $this->pagination->getListFooter(); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>" class="btn btn-outline-secondary">
|
|
||||||
← <?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS'); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
use Moko\Component\MokoJoomStoreLocator\Administrator\Helper\VideoHelper;
|
|
||||||
|
|
||||||
/** @var \Moko\Component\MokoJoomStoreLocator\Site\View\Location\HtmlView $this */
|
|
||||||
$item = $this->item;
|
|
||||||
$embedUrl = !empty($item->video_url) ? VideoHelper::getEmbedUrl($item->video_url) : null;
|
|
||||||
$gallery = !empty($item->images) ? array_filter(array_map('trim', explode("\n", $item->images))) : [];
|
|
||||||
|
|
||||||
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
|
|
||||||
$wa = $this->getDocument()->getWebAssetManager();
|
|
||||||
$wa->registerAndUseStyle('com_mokojoomstorelocator.site', 'components/com_mokojoomstorelocator/css/storelocator.css');
|
|
||||||
?>
|
|
||||||
<div class="com-mokojoomstorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
||||||
<h2 itemprop="name"><?php echo $this->escape($item->title); ?></h2>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary mokojoomstorelocator-print-btn" onclick="window.print()">
|
|
||||||
<span class="icon-print" aria-hidden="true"></span> <?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_PRINT'); ?>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (!empty($this->categories)) : ?>
|
|
||||||
<div class="mokojoomstorelocator-categories-tags mb-3">
|
|
||||||
<?php foreach ($this->categories as $cat) : ?>
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=category&id=' . $cat->id . ':' . $cat->alias); ?>">
|
|
||||||
<span style="background:<?php echo $this->escape($cat->color ?: '#6b7280'); ?>"><?php echo $this->escape($cat->title); ?></span>
|
|
||||||
</a>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-7">
|
|
||||||
<?php if ($item->image) : ?>
|
|
||||||
<div class="mokojoomstorelocator-image mb-3">
|
|
||||||
<img src="<?php echo $this->escape($item->image); ?>"
|
|
||||||
alt="<?php echo $this->escape($item->title); ?>"
|
|
||||||
itemprop="image" class="img-fluid rounded" loading="lazy" />
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (!empty($gallery)) : ?>
|
|
||||||
<div class="mokojoomstorelocator-gallery mb-3">
|
|
||||||
<?php foreach ($gallery as $img) : ?>
|
|
||||||
<img src="<?php echo $this->escape($img); ?>"
|
|
||||||
alt="<?php echo $this->escape($item->title); ?>"
|
|
||||||
loading="lazy" />
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->description) : ?>
|
|
||||||
<div class="mokojoomstorelocator-location-description mb-3" itemprop="description">
|
|
||||||
<?php echo $item->description; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($embedUrl) : ?>
|
|
||||||
<div class="mokojoomstorelocator-video mb-3">
|
|
||||||
<iframe src="<?php echo $this->escape($embedUrl); ?>"
|
|
||||||
allowfullscreen loading="lazy"
|
|
||||||
title="<?php echo $this->escape($item->title); ?>"></iframe>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="mokojoomstorelocator-location-details card mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_CONTACT'); ?></h4>
|
|
||||||
|
|
||||||
<?php if ($item->address || $item->city || $item->state) : ?>
|
|
||||||
<div class="mb-2" itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
|
|
||||||
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELDSET_ADDRESS'); ?>:</strong><br>
|
|
||||||
<?php if ($item->address) : ?>
|
|
||||||
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($item->city) : ?>
|
|
||||||
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($item->state) : ?>
|
|
||||||
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($item->postcode) : ?>
|
|
||||||
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($item->country) : ?>
|
|
||||||
<br><span itemprop="addressCountry"><?php echo $this->escape($item->country); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->phone) : ?>
|
|
||||||
<div class="mb-2">
|
|
||||||
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_PHONE'); ?>:</strong>
|
|
||||||
<a href="tel:<?php echo $this->escape($item->phone); ?>" itemprop="telephone">
|
|
||||||
<?php echo $this->escape($item->phone); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->email) : ?>
|
|
||||||
<div class="mb-2">
|
|
||||||
<strong><?php echo Text::_('JGLOBAL_EMAIL'); ?>:</strong>
|
|
||||||
<a href="mailto:<?php echo $this->escape($item->email); ?>" itemprop="email">
|
|
||||||
<?php echo $this->escape($item->email); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->website) : ?>
|
|
||||||
<div class="mb-2">
|
|
||||||
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_WEBSITE'); ?>:</strong>
|
|
||||||
<a href="<?php echo $this->escape($item->website); ?>" itemprop="url" target="_blank" rel="noopener">
|
|
||||||
<?php echo $this->escape(parse_url($item->website, PHP_URL_HOST) ?: $item->website); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->hours) : ?>
|
|
||||||
<div class="mb-2">
|
|
||||||
<strong><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_HOURS'); ?>:</strong><br>
|
|
||||||
<span itemprop="openingHours"><?php echo nl2br($this->escape($item->hours)); ?></span>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if ($item->latitude && $item->longitude) : ?>
|
|
||||||
<div class="mokojoomstorelocator-directions mb-3">
|
|
||||||
<a href="https://www.google.com/maps/dir/?api=1&destination=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>"
|
|
||||||
class="btn btn-primary" target="_blank" rel="noopener">
|
|
||||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS'); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<img class="mokojoomstorelocator-print-map" alt="Map"
|
|
||||||
src="https://staticmap.openstreetmap.de/staticmap.php?center=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>&zoom=14&size=300x200&markers=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>,ol-marker" />
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php // Render Joomla custom fields if any ?>
|
|
||||||
<?php $fields = \Joomla\CMS\Helper\FieldsHelper::getFields('com_mokojoomstorelocator.location', $item, true); ?>
|
|
||||||
<?php if (!empty($fields)) : ?>
|
|
||||||
<div class="mokojoomstorelocator-customfields card mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_ADDITIONAL_INFO'); ?></h4>
|
|
||||||
<?php foreach ($fields as $field) : ?>
|
|
||||||
<?php if ($field->value) : ?>
|
|
||||||
<div class="mb-1">
|
|
||||||
<strong><?php echo $this->escape($field->label); ?>:</strong>
|
|
||||||
<?php echo $field->value; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->email) : ?>
|
|
||||||
<div class="mokojoomstorelocator-contact card mb-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h4 class="card-title"><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_TITLE'); ?></h4>
|
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&task=contact.send'); ?>"
|
|
||||||
method="post" class="mokojoomstorelocator-contact-form">
|
|
||||||
<div class="mb-2">
|
|
||||||
<input type="text" name="contact_name" class="form-control" required
|
|
||||||
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_NAME'); ?>" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<input type="email" name="contact_email" class="form-control" required
|
|
||||||
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_EMAIL'); ?>" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<input type="text" name="contact_subject" class="form-control"
|
|
||||||
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SUBJECT'); ?>" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<textarea name="contact_message" class="form-control" rows="4" required
|
|
||||||
placeholder="<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_MESSAGE'); ?>"></textarea>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="location_id" value="<?php echo (int) $item->id; ?>" />
|
|
||||||
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_CONTACT_SEND'); ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-5">
|
|
||||||
<?php if ($item->latitude && $item->longitude) : ?>
|
|
||||||
<div id="mokojoomstorelocator-detail-map" style="height: 350px; border-radius: 8px;"></div>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var lat = <?php echo (float) $item->latitude; ?>;
|
|
||||||
var lng = <?php echo (float) $item->longitude; ?>;
|
|
||||||
var map = L.map('mokojoomstorelocator-detail-map').setView([lat, lng], 15);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
maxZoom: 19
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
L.marker([lat, lng]).addTo(map);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>" class="btn btn-outline-secondary">
|
|
||||||
← <?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_BACK_TO_LOCATIONS'); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* @package MokoJoomStoreLocator
|
|
||||||
* @subpackage com_mokojoomstorelocator
|
|
||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Language\Text;
|
|
||||||
use Joomla\CMS\Router\Route;
|
|
||||||
|
|
||||||
/** @var \Moko\Component\MokoJoomStoreLocator\Site\View\Locations\HtmlView $this */
|
|
||||||
?>
|
|
||||||
<div class="com-mokojoomstorelocator-locations">
|
|
||||||
<h2><?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_LOCATIONS'); ?></h2>
|
|
||||||
|
|
||||||
<?php if (empty($this->items)) : ?>
|
|
||||||
<p><?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS'); ?></p>
|
|
||||||
<?php else : ?>
|
|
||||||
<div class="mokojoomstorelocator-list">
|
|
||||||
<?php foreach ($this->items as $item) : ?>
|
|
||||||
<div class="mokojoomstorelocator-location" itemscope itemtype="https://schema.org/LocalBusiness">
|
|
||||||
<h3 itemprop="name">
|
|
||||||
<a href="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=location&id=' . $item->id . ':' . $item->alias); ?>">
|
|
||||||
<?php echo $this->escape($item->title); ?>
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="mokojoomstorelocator-address" itemprop="address" itemscope itemtype="https://schema.org/PostalAddress">
|
|
||||||
<?php if ($item->address) : ?>
|
|
||||||
<span itemprop="streetAddress"><?php echo $this->escape($item->address); ?></span><br>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($item->city) : ?>
|
|
||||||
<span itemprop="addressLocality"><?php echo $this->escape($item->city); ?></span>,
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($item->state) : ?>
|
|
||||||
<span itemprop="addressRegion"><?php echo $this->escape($item->state); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($item->postcode) : ?>
|
|
||||||
<span itemprop="postalCode"><?php echo $this->escape($item->postcode); ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if ($item->phone) : ?>
|
|
||||||
<div class="mokojoomstorelocator-phone">
|
|
||||||
<a href="tel:<?php echo $this->escape($item->phone); ?>" itemprop="telephone">
|
|
||||||
<?php echo $this->escape($item->phone); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->website) : ?>
|
|
||||||
<div class="mokojoomstorelocator-website">
|
|
||||||
<a href="<?php echo $this->escape($item->website); ?>" itemprop="url" target="_blank" rel="noopener">
|
|
||||||
<?php echo $this->escape(parse_url($item->website, PHP_URL_HOST) ?: $item->website); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->hours) : ?>
|
|
||||||
<div class="mokojoomstorelocator-hours" itemprop="openingHours">
|
|
||||||
<?php echo nl2br($this->escape($item->hours)); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->image) : ?>
|
|
||||||
<div class="mokojoomstorelocator-image">
|
|
||||||
<img src="<?php echo $this->escape($item->image); ?>"
|
|
||||||
alt="<?php echo $this->escape($item->title); ?>"
|
|
||||||
itemprop="image" loading="lazy" />
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (!empty($item->distance)) : ?>
|
|
||||||
<div class="mokojoomstorelocator-distance">
|
|
||||||
<small class="text-muted"><?php echo number_format($item->distance, 1); ?> away</small>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($item->latitude && $item->longitude) : ?>
|
|
||||||
<div class="mokojoomstorelocator-directions">
|
|
||||||
<a href="https://www.google.com/maps/dir/?api=1&destination=<?php echo (float) $item->latitude; ?>,<?php echo (float) $item->longitude; ?>"
|
|
||||||
target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
|
|
||||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS'); ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php echo $this->pagination->getListFooter(); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
; MokoJoomStoreLocator Map Module - Language strings
|
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
; License: GNU General Public License version 3 or later; see LICENSE
|
|
||||||
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP="Store Locator Map"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP_DESC="Displays an interactive map with store location markers."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP_HEIGHT="Map Height"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP_ZOOM="Default Zoom Level"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP_PROVIDER="Map Provider"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY="API Key"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP_API_KEY_DESC="Required for Google Maps. Not needed for OpenStreetMap."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT="JavaScript is required to display the map."
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
; MokoJoomStoreLocator Map Module - System language strings
|
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
; License: GNU General Public License version 3 or later; see LICENSE
|
|
||||||
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP="Store Locator Map"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_MAP_DESC="Displays an interactive map with store location markers."
|
|
||||||
@@ -13,15 +13,15 @@
|
|||||||
=========================================================================
|
=========================================================================
|
||||||
-->
|
-->
|
||||||
<extension type="module" client="site" method="upgrade">
|
<extension type="module" client="site" method="upgrade">
|
||||||
<name>Moko Store Locator - Map</name>
|
<name>mod_mokojoomstorelocator_map</name>
|
||||||
<version>00.00.01</version>
|
<version>1.0.0</version>
|
||||||
<creationDate>2026-05-22</creationDate>
|
<creationDate>2026-05-21</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||||
<description>Displays an interactive map with store location markers.</description>
|
<description>MOD_MOKOJOOMSTORELOCATOR_MAP_DESC</description>
|
||||||
|
|
||||||
<namespace path="src">Moko\Module\MokoJoomStoreLocatorMap</namespace>
|
<namespace path="src">Moko\Module\MokoJoomStoreLocatorMap</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -13,113 +13,30 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||||
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
|
use Joomla\CMS\Helper\HelperFactoryAwareInterface;
|
||||||
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
|
use Joomla\CMS\Helper\HelperFactoryAwareTrait;
|
||||||
use Joomla\Database\DatabaseInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatcher for mod_mokojoomstorelocator_map.
|
* Dispatcher for mod_mokojoomstorelocator_map.
|
||||||
*
|
*
|
||||||
* Loads locations with category data (color, marker icon) for map rendering.
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface
|
class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareInterface
|
||||||
{
|
{
|
||||||
use HelperFactoryAwareTrait;
|
use HelperFactoryAwareTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the layout data.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
protected function getLayoutData(): array
|
protected function getLayoutData(): array
|
||||||
{
|
{
|
||||||
$data = parent::getLayoutData();
|
$data = parent::getLayoutData();
|
||||||
|
|
||||||
$db = \Joomla\CMS\Factory::getContainer()->get(DatabaseInterface::class);
|
// TODO: Load published locations with coordinates from the component table
|
||||||
$query = $db->getQuery(true);
|
// TODO: Build marker data array for the map JS
|
||||||
|
$data['locations'] = [];
|
||||||
$query->select([
|
|
||||||
$db->quoteName('l.id'),
|
|
||||||
$db->quoteName('l.title'),
|
|
||||||
$db->quoteName('l.address'),
|
|
||||||
$db->quoteName('l.city'),
|
|
||||||
$db->quoteName('l.state'),
|
|
||||||
$db->quoteName('l.postcode'),
|
|
||||||
$db->quoteName('l.phone'),
|
|
||||||
$db->quoteName('l.latitude'),
|
|
||||||
$db->quoteName('l.longitude'),
|
|
||||||
])
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_locations', 'l'))
|
|
||||||
->where($db->quoteName('l.published') . ' = 1')
|
|
||||||
->where($db->quoteName('l.latitude') . ' IS NOT NULL')
|
|
||||||
->where($db->quoteName('l.longitude') . ' IS NOT NULL');
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
$locations = $db->loadObjectList();
|
|
||||||
|
|
||||||
// Load categories for all locations in one query
|
|
||||||
$locationIds = array_column((array) $locations, 'id');
|
|
||||||
$categoryMap = [];
|
|
||||||
|
|
||||||
if (!empty($locationIds))
|
|
||||||
{
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select([
|
|
||||||
$db->quoteName('lc.location_id'),
|
|
||||||
$db->quoteName('c.id', 'cat_id'),
|
|
||||||
$db->quoteName('c.title', 'cat_title'),
|
|
||||||
$db->quoteName('c.color', 'cat_color'),
|
|
||||||
$db->quoteName('c.marker_icon', 'cat_marker'),
|
|
||||||
])
|
|
||||||
->from($db->quoteName('#__mokojoomstorelocator_location_categories', 'lc'))
|
|
||||||
->join('INNER', $db->quoteName('#__mokojoomstorelocator_categories', 'c') . ' ON c.id = lc.category_id AND c.published = 1')
|
|
||||||
->where($db->quoteName('lc.location_id') . ' IN (' . implode(',', array_map('intval', $locationIds)) . ')');
|
|
||||||
|
|
||||||
$db->setQuery($query);
|
|
||||||
$catRows = $db->loadObjectList();
|
|
||||||
|
|
||||||
foreach ($catRows as $row)
|
|
||||||
{
|
|
||||||
$categoryMap[(int) $row->location_id][] = [
|
|
||||||
'id' => (int) $row->cat_id,
|
|
||||||
'title' => $row->cat_title,
|
|
||||||
'color' => $row->cat_color,
|
|
||||||
'marker' => $row->cat_marker,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build marker data with category info
|
|
||||||
$markers = [];
|
|
||||||
$categories = [];
|
|
||||||
|
|
||||||
foreach ($locations as $loc)
|
|
||||||
{
|
|
||||||
$locCats = $categoryMap[(int) $loc->id] ?? [];
|
|
||||||
$primaryCat = $locCats[0] ?? null;
|
|
||||||
|
|
||||||
$markers[] = [
|
|
||||||
'id' => (int) $loc->id,
|
|
||||||
'title' => $loc->title,
|
|
||||||
'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '),
|
|
||||||
'phone' => $loc->phone,
|
|
||||||
'lat' => (float) $loc->latitude,
|
|
||||||
'lng' => (float) $loc->longitude,
|
|
||||||
'color' => $primaryCat['color'] ?? '#3b82f6',
|
|
||||||
'marker' => $primaryCat['marker'] ?? '',
|
|
||||||
'categories' => array_column($locCats, 'id'),
|
|
||||||
'cat_names' => array_column($locCats, 'title'),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Collect unique categories for legend
|
|
||||||
foreach ($locCats as $cat)
|
|
||||||
{
|
|
||||||
$categories[$cat['id']] = [
|
|
||||||
'id' => $cat['id'],
|
|
||||||
'title' => $cat['title'],
|
|
||||||
'color' => $cat['color'],
|
|
||||||
'marker' => $cat['marker'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['locations'] = $markers;
|
|
||||||
$data['categories'] = array_values($categories);
|
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,210 +16,15 @@ $locations = $displayData['locations'] ?? [];
|
|||||||
$mapHeight = $params->get('map_height', '400px');
|
$mapHeight = $params->get('map_height', '400px');
|
||||||
$mapZoom = (int) $params->get('map_zoom', 10);
|
$mapZoom = (int) $params->get('map_zoom', 10);
|
||||||
$provider = $params->get('map_provider', 'leaflet');
|
$provider = $params->get('map_provider', 'leaflet');
|
||||||
$apiKey = $params->get('api_key', '');
|
|
||||||
$moduleId = $displayData['module']->id;
|
|
||||||
?>
|
?>
|
||||||
<div class="mod-mokojoomstorelocator-map"
|
<div class="mod-mokojoomstorelocator-map"
|
||||||
id="mokojoomstorelocator-map-<?php echo $moduleId; ?>"
|
id="mokojoomstorelocator-map-<?php echo $displayData['module']->id; ?>"
|
||||||
style="height: <?php echo $this->escape($mapHeight); ?>;"
|
style="height: <?php echo $this->escape($mapHeight); ?>;"
|
||||||
|
data-locations="<?php echo $this->escape(json_encode($locations)); ?>"
|
||||||
|
data-zoom="<?php echo $mapZoom; ?>"
|
||||||
data-provider="<?php echo $this->escape($provider); ?>">
|
data-provider="<?php echo $this->escape($provider); ?>">
|
||||||
|
<!-- TODO: Map renders here via JavaScript -->
|
||||||
<noscript>
|
<noscript>
|
||||||
<p><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT'); ?></p>
|
<p><?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_MAP_NOSCRIPT'); ?></p>
|
||||||
</noscript>
|
</noscript>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($provider === 'leaflet') : ?>
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" crossorigin="" />
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" crossorigin="" />
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
|
||||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js" crossorigin=""></script>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($provider === 'google' && $apiKey) : ?>
|
|
||||||
<script src="https://maps.googleapis.com/maps/api/js?key=<?php echo $this->escape($apiKey); ?>&libraries=marker&callback=Function.prototype" async defer></script>
|
|
||||||
<script src="https://unpkg.com/@googlemaps/markerclusterer@2.5.3/dist/index.min.js"></script>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var container = document.getElementById('mokojoomstorelocator-map-<?php echo $moduleId; ?>');
|
|
||||||
var locations = <?php echo json_encode($locations); ?>;
|
|
||||||
var defaultZoom = <?php echo $mapZoom; ?>;
|
|
||||||
var provider = '<?php echo $this->escape($provider); ?>';
|
|
||||||
|
|
||||||
if (!locations.length) {
|
|
||||||
var msg = document.createElement('p');
|
|
||||||
msg.style.cssText = 'text-align:center;padding:2em;color:#6c757d;';
|
|
||||||
msg.textContent = '<?php echo Text::_('JGLOBAL_NO_MATCHING_RESULTS', true); ?>';
|
|
||||||
container.appendChild(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate center from locations
|
|
||||||
var avgLat = 0, avgLng = 0;
|
|
||||||
locations.forEach(function(loc) { avgLat += loc.lat; avgLng += loc.lng; });
|
|
||||||
avgLat /= locations.length;
|
|
||||||
avgLng /= locations.length;
|
|
||||||
|
|
||||||
var categories = <?php echo json_encode($displayData['categories'] ?? []); ?>;
|
|
||||||
|
|
||||||
if (provider === 'leaflet') {
|
|
||||||
initLeaflet(container, locations, categories, avgLat, avgLng, defaultZoom);
|
|
||||||
} else if (provider === 'google') {
|
|
||||||
initGoogle(container, locations, categories, avgLat, avgLng, defaultZoom);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build category legend
|
|
||||||
if (categories.length > 0) {
|
|
||||||
var legend = document.createElement('div');
|
|
||||||
legend.className = 'mokojoomstorelocator-legend';
|
|
||||||
categories.forEach(function(cat) {
|
|
||||||
var item = document.createElement('span');
|
|
||||||
item.className = 'mokojoomstorelocator-legend-item';
|
|
||||||
|
|
||||||
var swatch = document.createElement('span');
|
|
||||||
swatch.className = 'mokojoomstorelocator-legend-swatch';
|
|
||||||
swatch.style.background = cat.color;
|
|
||||||
item.appendChild(swatch);
|
|
||||||
|
|
||||||
var label = document.createElement('span');
|
|
||||||
label.textContent = cat.title;
|
|
||||||
item.appendChild(label);
|
|
||||||
|
|
||||||
legend.appendChild(item);
|
|
||||||
});
|
|
||||||
container.parentNode.insertBefore(legend, container.nextSibling);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildPopupContent(loc) {
|
|
||||||
var wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'mokojoomstorelocator-popup';
|
|
||||||
|
|
||||||
var title = document.createElement('strong');
|
|
||||||
title.textContent = loc.title;
|
|
||||||
wrapper.appendChild(title);
|
|
||||||
|
|
||||||
if (loc.address) {
|
|
||||||
wrapper.appendChild(document.createElement('br'));
|
|
||||||
var addr = document.createElement('small');
|
|
||||||
addr.textContent = loc.address;
|
|
||||||
wrapper.appendChild(addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loc.phone) {
|
|
||||||
wrapper.appendChild(document.createElement('br'));
|
|
||||||
var phoneLink = document.createElement('a');
|
|
||||||
phoneLink.href = 'tel:' + loc.phone;
|
|
||||||
phoneLink.textContent = loc.phone;
|
|
||||||
wrapper.appendChild(phoneLink);
|
|
||||||
}
|
|
||||||
|
|
||||||
wrapper.appendChild(document.createElement('br'));
|
|
||||||
var dirLink = document.createElement('a');
|
|
||||||
dirLink.href = 'https://www.google.com/maps/dir/?api=1&destination=' + loc.lat + ',' + loc.lng;
|
|
||||||
dirLink.target = '_blank';
|
|
||||||
dirLink.rel = 'noopener';
|
|
||||||
dirLink.textContent = '<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_GET_DIRECTIONS', true); ?>';
|
|
||||||
wrapper.appendChild(dirLink);
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLeafletIcon(loc) {
|
|
||||||
// Custom marker icon takes priority
|
|
||||||
if (loc.marker) {
|
|
||||||
return L.icon({
|
|
||||||
iconUrl: loc.marker,
|
|
||||||
iconSize: [32, 32],
|
|
||||||
iconAnchor: [16, 32],
|
|
||||||
popupAnchor: [0, -32]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color-coded circle marker via SVG data URI
|
|
||||||
var color = loc.color || '#3b82f6';
|
|
||||||
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="36" viewBox="0 0 24 36">'
|
|
||||||
+ '<path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="' + color + '"/>'
|
|
||||||
+ '<circle cx="12" cy="12" r="5" fill="white"/></svg>';
|
|
||||||
return L.icon({
|
|
||||||
iconUrl: 'data:image/svg+xml;base64,' + btoa(svg),
|
|
||||||
iconSize: [24, 36],
|
|
||||||
iconAnchor: [12, 36],
|
|
||||||
popupAnchor: [0, -36]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initLeaflet(container, locations, categories, centerLat, centerLng, zoom) {
|
|
||||||
var map = L.map(container).setView([centerLat, centerLng], zoom);
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
||||||
maxZoom: 19
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
var markers = L.markerClusterGroup();
|
|
||||||
|
|
||||||
locations.forEach(function(loc) {
|
|
||||||
var marker = L.marker([loc.lat, loc.lng], { icon: createLeafletIcon(loc) });
|
|
||||||
var popup = L.popup().setContent(buildPopupContent(loc));
|
|
||||||
marker.bindPopup(popup);
|
|
||||||
marker._categoryIds = loc.categories || [];
|
|
||||||
markers.addLayer(marker);
|
|
||||||
});
|
|
||||||
|
|
||||||
map.addLayer(markers);
|
|
||||||
|
|
||||||
if (locations.length > 1) {
|
|
||||||
var bounds = markers.getBounds();
|
|
||||||
map.fitBounds(bounds, { padding: [30, 30] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initGoogle(container, locations, categories, centerLat, centerLng, zoom) {
|
|
||||||
var map = new google.maps.Map(container, {
|
|
||||||
center: { lat: centerLat, lng: centerLng },
|
|
||||||
zoom: zoom
|
|
||||||
});
|
|
||||||
|
|
||||||
var infoWindow = new google.maps.InfoWindow();
|
|
||||||
var gMarkers = [];
|
|
||||||
|
|
||||||
locations.forEach(function(loc) {
|
|
||||||
var marker = new google.maps.Marker({
|
|
||||||
position: { lat: loc.lat, lng: loc.lng },
|
|
||||||
map: map,
|
|
||||||
title: loc.title
|
|
||||||
});
|
|
||||||
|
|
||||||
marker.addListener('click', function() {
|
|
||||||
infoWindow.setContent(buildPopupContent(loc));
|
|
||||||
infoWindow.open(map, marker);
|
|
||||||
});
|
|
||||||
|
|
||||||
gMarkers.push(marker);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof markerClusterer !== 'undefined') {
|
|
||||||
new markerClusterer.MarkerClusterer({ map: map, markers: gMarkers });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fit bounds
|
|
||||||
if (locations.length > 1) {
|
|
||||||
var bounds = new google.maps.LatLngBounds();
|
|
||||||
locations.forEach(function(loc) {
|
|
||||||
bounds.extend({ lat: loc.lat, lng: loc.lng });
|
|
||||||
});
|
|
||||||
map.fitBounds(bounds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.mokojoomstorelocator-popup { font-size: 13px; line-height: 1.5; }
|
|
||||||
.mokojoomstorelocator-popup strong { font-size: 14px; }
|
|
||||||
.mokojoomstorelocator-legend { padding: 8px 0; display: flex; flex-wrap: wrap; gap: 12px; }
|
|
||||||
.mokojoomstorelocator-legend-item { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; }
|
|
||||||
.mokojoomstorelocator-legend-swatch { display: inline-block; width: 14px; height: 14px; border-radius: 50%; }
|
|
||||||
</style>
|
|
||||||
|
|||||||
-8
@@ -11,11 +11,3 @@ MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_RADIUS="Show Radius Filter"
|
|||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT="Distance Unit"
|
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT="Distance Unit"
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS="Radius Options"
|
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS="Radius Options"
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS_DESC="Comma-separated list of radius values (e.g., 5,10,25,50,100)"
|
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS_DESC="Comma-separated list of radius values (e.g., 5,10,25,50,100)"
|
||||||
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION="Use My Location"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING="Finding your location..."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_FOUND="Location found! Select a radius and search."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_DENIED="Location access denied. Please enable location services."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_UNAVAILABLE="Location information unavailable."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_TIMEOUT="Location request timed out."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_ERROR="Unable to determine your location."
|
|
||||||
|
|||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
; MokoJoomStoreLocator Search Module - Language strings
|
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
; License: GNU General Public License version 3 or later; see LICENSE
|
|
||||||
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH="Store Locator Search"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC="Provides a search/filter form for finding store locations."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL="Find a Store"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER="Enter city, postcode, or address..."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_CITY="Show City Filter"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_SHOW_RADIUS="Show Radius Filter"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT="Distance Unit"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS="Radius Options"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_OPTIONS_DESC="Comma-separated list of radius values (e.g., 5,10,25,50,100)"
|
|
||||||
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION="Use My Location"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING="Finding your location..."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_FOUND="Location found! Select a radius and search."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_DENIED="Location access denied. Please enable location services."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_UNAVAILABLE="Location information unavailable."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_TIMEOUT="Location request timed out."
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_ERROR="Unable to determine your location."
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
; MokoJoomStoreLocator Search Module - System language strings
|
|
||||||
; Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
||||||
; License: GNU General Public License version 3 or later; see LICENSE
|
|
||||||
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH="Store Locator Search"
|
|
||||||
MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC="Provides a search/filter form for finding store locations."
|
|
||||||
@@ -13,15 +13,15 @@
|
|||||||
=========================================================================
|
=========================================================================
|
||||||
-->
|
-->
|
||||||
<extension type="module" client="site" method="upgrade">
|
<extension type="module" client="site" method="upgrade">
|
||||||
<name>Moko Store Locator - Search</name>
|
<name>mod_mokojoomstorelocator_search</name>
|
||||||
<version>00.00.01</version>
|
<version>1.0.0</version>
|
||||||
<creationDate>2026-05-22</creationDate>
|
<creationDate>2026-05-21</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||||
<description>Provides a search and filter form for finding store locations.</description>
|
<description>MOD_MOKOJOOMSTORELOCATOR_SEARCH_DESC</description>
|
||||||
|
|
||||||
<namespace path="src">Moko\Module\MokoJoomStoreLocatorSearch</namespace>
|
<namespace path="src">Moko\Module\MokoJoomStoreLocatorSearch</namespace>
|
||||||
|
|
||||||
|
|||||||
@@ -34,34 +34,8 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI
|
|||||||
{
|
{
|
||||||
$data = parent::getLayoutData();
|
$data = parent::getLayoutData();
|
||||||
|
|
||||||
$db = \Joomla\CMS\Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
// TODO: Load distinct cities/states for filter dropdowns
|
||||||
$table = $db->quoteName('#__mokojoomstorelocator_locations');
|
// TODO: Build radius options array from params
|
||||||
|
|
||||||
// Load distinct cities
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select('DISTINCT ' . $db->quoteName('city'))
|
|
||||||
->from($table)
|
|
||||||
->where($db->quoteName('published') . ' = 1')
|
|
||||||
->where($db->quoteName('city') . ' != ' . $db->quote(''))
|
|
||||||
->order($db->quoteName('city') . ' ASC');
|
|
||||||
$db->setQuery($query);
|
|
||||||
$data['cities'] = $db->loadColumn();
|
|
||||||
|
|
||||||
// Load distinct states
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select('DISTINCT ' . $db->quoteName('state'))
|
|
||||||
->from($table)
|
|
||||||
->where($db->quoteName('published') . ' = 1')
|
|
||||||
->where($db->quoteName('state') . ' != ' . $db->quote(''))
|
|
||||||
->order($db->quoteName('state') . ' ASC');
|
|
||||||
$db->setQuery($query);
|
|
||||||
$data['states'] = $db->loadColumn();
|
|
||||||
|
|
||||||
// Parse radius options from params
|
|
||||||
$params = $data['params'];
|
|
||||||
$radiusString = $params->get('radius_options', '5,10,25,50,100');
|
|
||||||
$data['radius_options'] = array_map('intval', explode(',', $radiusString));
|
|
||||||
$data['radius_unit'] = $params->get('radius_unit', 'miles');
|
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,143 +12,32 @@ use Joomla\CMS\Language\Text;
|
|||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
|
|
||||||
/** @var array $displayData */
|
/** @var array $displayData */
|
||||||
$params = $displayData['params'];
|
$params = $displayData['params'];
|
||||||
$moduleId = $displayData['module']->id;
|
|
||||||
?>
|
?>
|
||||||
<div class="mod-mokojoomstorelocator-search" id="mokojoomstorelocator-search-<?php echo $moduleId; ?>">
|
<div class="mod-mokojoomstorelocator-search">
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>"
|
<form action="<?php echo Route::_('index.php?option=com_mokojoomstorelocator&view=locations'); ?>"
|
||||||
method="get" class="mokojoomstorelocator-search-form">
|
method="get" class="mokojoomstorelocator-search-form">
|
||||||
|
|
||||||
<div class="mokojoomstorelocator-search-field">
|
<div class="mokojoomstorelocator-search-field">
|
||||||
<label for="mokojoomstorelocator-query-<?php echo $moduleId; ?>">
|
<label for="mokojoomstorelocator-query">
|
||||||
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL'); ?>
|
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LABEL'); ?>
|
||||||
</label>
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="mokojoomstorelocator-query-<?php echo $moduleId; ?>"
|
id="mokojoomstorelocator-query"
|
||||||
name="filter_search"
|
name="filter_search"
|
||||||
class="form-control"
|
|
||||||
placeholder="<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER'); ?>"
|
placeholder="<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_PLACEHOLDER'); ?>"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($params->get('show_city_filter', 1) && !empty($displayData['cities'])) : ?>
|
<!-- TODO: City dropdown filter -->
|
||||||
<div class="mokojoomstorelocator-search-field mt-2">
|
<!-- TODO: Radius dropdown filter -->
|
||||||
<label for="mokojoomstorelocator-city-<?php echo $moduleId; ?>">
|
<!-- TODO: Geolocation "Use my location" button -->
|
||||||
<?php echo Text::_('COM_MOKOJOOMSTORELOCATOR_FIELD_CITY'); ?>
|
|
||||||
</label>
|
|
||||||
<select id="mokojoomstorelocator-city-<?php echo $moduleId; ?>" name="filter_city" class="form-select">
|
|
||||||
<option value=""><?php echo Text::_('JALL'); ?></option>
|
|
||||||
<?php foreach ($displayData['cities'] as $city) : ?>
|
|
||||||
<option value="<?php echo $this->escape($city); ?>"><?php echo $this->escape($city); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($params->get('show_radius_filter', 1) && !empty($displayData['radius_options'])) : ?>
|
<button type="submit" class="btn btn-primary">
|
||||||
<div class="mokojoomstorelocator-search-field mt-2">
|
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
|
||||||
<label for="mokojoomstorelocator-radius-<?php echo $moduleId; ?>">
|
</button>
|
||||||
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_RADIUS_UNIT'); ?>
|
|
||||||
</label>
|
|
||||||
<select id="mokojoomstorelocator-radius-<?php echo $moduleId; ?>" name="filter_radius" class="form-select">
|
|
||||||
<option value=""><?php echo Text::_('JALL'); ?></option>
|
|
||||||
<?php foreach ($displayData['radius_options'] as $r) : ?>
|
|
||||||
<option value="<?php echo (int) $r; ?>"><?php echo (int) $r; ?> <?php echo $this->escape($displayData['radius_unit']); ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="mokojoomstorelocator-lat-<?php echo $moduleId; ?>" name="filter_latitude" value="" />
|
|
||||||
<input type="hidden" id="mokojoomstorelocator-lng-<?php echo $moduleId; ?>" name="filter_longitude" value="" />
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="mokojoomstorelocator-search-actions mt-3">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<?php echo Text::_('JSEARCH_FILTER_SUBMIT'); ?>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-outline-secondary ms-2"
|
|
||||||
id="mokojoomstorelocator-geolocate-<?php echo $moduleId; ?>"
|
|
||||||
title="<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION'); ?>">
|
|
||||||
<span class="icon-location" aria-hidden="true"></span>
|
|
||||||
<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_USE_LOCATION'); ?>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="mokojoomstorelocator-geostatus-<?php echo $moduleId; ?>"
|
|
||||||
class="mokojoomstorelocator-geostatus mt-2" style="display:none;"></div>
|
|
||||||
|
|
||||||
<input type="hidden" name="option" value="com_mokojoomstorelocator" />
|
<input type="hidden" name="option" value="com_mokojoomstorelocator" />
|
||||||
<input type="hidden" name="view" value="locations" />
|
<input type="hidden" name="view" value="locations" />
|
||||||
<input type="hidden" name="filter_radius_unit" value="<?php echo $this->escape($displayData['radius_unit'] ?? 'miles'); ?>" />
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var moduleId = '<?php echo $moduleId; ?>';
|
|
||||||
var geoBtn = document.getElementById('mokojoomstorelocator-geolocate-' + moduleId);
|
|
||||||
var statusEl = document.getElementById('mokojoomstorelocator-geostatus-' + moduleId);
|
|
||||||
var latField = document.getElementById('mokojoomstorelocator-lat-' + moduleId);
|
|
||||||
var lngField = document.getElementById('mokojoomstorelocator-lng-' + moduleId);
|
|
||||||
var radiusEl = document.getElementById('mokojoomstorelocator-radius-' + moduleId);
|
|
||||||
|
|
||||||
if (!geoBtn) return;
|
|
||||||
|
|
||||||
if (!navigator.geolocation) {
|
|
||||||
geoBtn.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
geoBtn.addEventListener('click', function() {
|
|
||||||
geoBtn.disabled = true;
|
|
||||||
showStatus('<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATING', true); ?>', 'info');
|
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
function(position) {
|
|
||||||
var lat = position.coords.latitude;
|
|
||||||
var lng = position.coords.longitude;
|
|
||||||
|
|
||||||
if (latField) latField.value = lat.toFixed(8);
|
|
||||||
if (lngField) lngField.value = lng.toFixed(8);
|
|
||||||
|
|
||||||
// Auto-select first radius if none selected
|
|
||||||
if (radiusEl && !radiusEl.value && radiusEl.options.length > 1) {
|
|
||||||
radiusEl.selectedIndex = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
showStatus('<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_LOCATION_FOUND', true); ?>', 'success');
|
|
||||||
geoBtn.disabled = false;
|
|
||||||
},
|
|
||||||
function(error) {
|
|
||||||
var msg;
|
|
||||||
switch (error.code) {
|
|
||||||
case error.PERMISSION_DENIED:
|
|
||||||
msg = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_DENIED', true); ?>';
|
|
||||||
break;
|
|
||||||
case error.POSITION_UNAVAILABLE:
|
|
||||||
msg = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_UNAVAILABLE', true); ?>';
|
|
||||||
break;
|
|
||||||
case error.TIMEOUT:
|
|
||||||
msg = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_TIMEOUT', true); ?>';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
msg = '<?php echo Text::_('MOD_MOKOJOOMSTORELOCATOR_SEARCH_GEO_ERROR', true); ?>';
|
|
||||||
}
|
|
||||||
showStatus(msg, 'warning');
|
|
||||||
geoBtn.disabled = false;
|
|
||||||
},
|
|
||||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 300000 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function showStatus(message, type) {
|
|
||||||
statusEl.style.display = 'block';
|
|
||||||
statusEl.className = 'mokojoomstorelocator-geostatus mt-2 alert alert-' + type;
|
|
||||||
statusEl.textContent = message;
|
|
||||||
if (type === 'success') {
|
|
||||||
setTimeout(function() { statusEl.style.display = 'none'; }, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
PLG_WEBSERVICES_MOKOJOOMSTORELOCATOR_DESC="REST API endpoints for store locations and categories."
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
PLG_WEBSERVICES_MOKOJOOMSTORELOCATOR_DESC="REST API endpoints for store locations and categories."
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<extension type="plugin" group="webservices" method="upgrade">
|
|
||||||
<name>Web Services - Moko Store Locator</name>
|
|
||||||
<version>00.00.01</version>
|
|
||||||
<creationDate>2026-05-22</creationDate>
|
|
||||||
<author>Moko Consulting</author>
|
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
|
||||||
<description>Web Services API for the MokoJoomStoreLocator component.</description>
|
|
||||||
<namespace path="src">Moko\Plugin\WebServices\MokoJoomStoreLocator</namespace>
|
|
||||||
<files><folder>src</folder><folder>language</folder></files>
|
|
||||||
</extension>
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Web Services plugin for MokoJoomStoreLocator.
|
|
||||||
*
|
|
||||||
* Registers REST API routes:
|
|
||||||
* GET /api/index.php/v1/storelocator/locations — list locations
|
|
||||||
* GET /api/index.php/v1/storelocator/locations/{id} — single location
|
|
||||||
* GET /api/index.php/v1/storelocator/categories — list categories
|
|
||||||
* GET /api/index.php/v1/storelocator/categories/{id} — single category
|
|
||||||
* POST /api/index.php/v1/storelocator/locations — create location (admin)
|
|
||||||
* PATCH /api/index.php/v1/storelocator/locations/{id} — update location (admin)
|
|
||||||
* DELETE /api/index.php/v1/storelocator/locations/{id} — delete location (admin)
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Moko\Plugin\WebServices\MokoJoomStoreLocator\Extension;
|
|
||||||
|
|
||||||
defined('_JEXEC') or die;
|
|
||||||
|
|
||||||
use Joomla\CMS\Plugin\CMSPlugin;
|
|
||||||
use Joomla\CMS\Router\ApiRouter;
|
|
||||||
use Joomla\Event\SubscriberInterface;
|
|
||||||
use Joomla\Router\Route;
|
|
||||||
|
|
||||||
class MokoJoomStoreLocator extends CMSPlugin implements SubscriberInterface
|
|
||||||
{
|
|
||||||
public static function getSubscribedEvents(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'onBeforeApiRoute' => 'onBeforeApiRoute',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function onBeforeApiRoute(&$event): void
|
|
||||||
{
|
|
||||||
$router = $event->getArgument('router');
|
|
||||||
|
|
||||||
$router->createCRUDRoutes(
|
|
||||||
'v1/storelocator/locations',
|
|
||||||
'locations',
|
|
||||||
['component' => 'com_mokojoomstorelocator']
|
|
||||||
);
|
|
||||||
|
|
||||||
$router->createCRUDRoutes(
|
|
||||||
'v1/storelocator/categories',
|
|
||||||
'categories',
|
|
||||||
['component' => 'com_mokojoomstorelocator']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Custom route: search locations by radius
|
|
||||||
$router->addRoute(
|
|
||||||
new Route(
|
|
||||||
['GET'],
|
|
||||||
'v1/storelocator/search',
|
|
||||||
'locations.search',
|
|
||||||
[],
|
|
||||||
['component' => 'com_mokojoomstorelocator']
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,23 +16,22 @@
|
|||||||
=========================================================================
|
=========================================================================
|
||||||
-->
|
-->
|
||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Moko Joom Store Locator</name>
|
<name>pkg_mokojoomstorelocator</name>
|
||||||
<packagename>mokojoomstorelocator</packagename>
|
<packagename>mokojoomstorelocator</packagename>
|
||||||
<version>00.00.01</version>
|
<version>1.0.0</version>
|
||||||
<creationDate>2026-05-22</creationDate>
|
<creationDate>2026-05-21</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||||
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
<license>GNU General Public License version 3 or later; see LICENSE</license>
|
||||||
<description>Complete store locator suite for Joomla with map, search, and API support.</description>
|
<description>PKG_MOKOJOOMSTORELOCATOR_DESC</description>
|
||||||
<scriptfile>script.php</scriptfile>
|
<scriptfile>script.php</scriptfile>
|
||||||
|
|
||||||
<files>
|
<files>
|
||||||
<file type="component" id="com_mokojoomstorelocator">com_mokojoomstorelocator.zip</file>
|
<file type="component" id="com_mokojoomstorelocator">com_mokojoomstorelocator.zip</file>
|
||||||
<file type="module" id="mod_mokojoomstorelocator_map" client="site">mod_mokojoomstorelocator_map.zip</file>
|
<file type="module" id="mod_mokojoomstorelocator_map" client="site">mod_mokojoomstorelocator_map.zip</file>
|
||||||
<file type="module" id="mod_mokojoomstorelocator_search" client="site">mod_mokojoomstorelocator_search.zip</file>
|
<file type="module" id="mod_mokojoomstorelocator_search" client="site">mod_mokojoomstorelocator_search.zip</file>
|
||||||
<file type="plugin" id="plg_webservices_mokojoomstorelocator" group="webservices">plg_webservices_mokojoomstorelocator.zip</file>
|
|
||||||
</files>
|
</files>
|
||||||
|
|
||||||
<updateservers>
|
<updateservers>
|
||||||
|
|||||||
+2
-42
@@ -85,10 +85,7 @@ class Pkg_MokojoomstorelocatorInstallerScript
|
|||||||
*/
|
*/
|
||||||
public function postflight($type, $parent)
|
public function postflight($type, $parent)
|
||||||
{
|
{
|
||||||
if ($type === 'install')
|
// TODO: Post-installation tasks (enable modules, set defaults, etc.)
|
||||||
{
|
|
||||||
$this->enableModules();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -104,43 +101,6 @@ class Pkg_MokojoomstorelocatorInstallerScript
|
|||||||
*/
|
*/
|
||||||
public function uninstall($parent)
|
public function uninstall($parent)
|
||||||
{
|
{
|
||||||
Log::add('MokoJoomStoreLocator package uninstalled.', Log::INFO, 'jerror');
|
// TODO: Cleanup tasks on uninstall
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable the bundled modules after first install.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
private function enableModules(): void
|
|
||||||
{
|
|
||||||
$db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class);
|
|
||||||
|
|
||||||
$modules = [
|
|
||||||
'mod_mokojoomstorelocator_map',
|
|
||||||
'mod_mokojoomstorelocator_search',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($modules as $module)
|
|
||||||
{
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->update($db->quoteName('#__extensions'))
|
|
||||||
->set($db->quoteName('enabled') . ' = 1')
|
|
||||||
->where($db->quoteName('element') . ' = :element')
|
|
||||||
->where($db->quoteName('type') . ' = ' . $db->quote('module'))
|
|
||||||
->bind(':element', $module);
|
|
||||||
$db->setQuery($query);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
$db->execute();
|
|
||||||
}
|
|
||||||
catch (\Exception $e)
|
|
||||||
{
|
|
||||||
Log::add('Failed to enable ' . $module . ': ' . $e->getMessage(), Log::WARNING, 'jerror');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user