feat(teamstore): add league/conference sub-sub-category pill filter (hi-five-franchise-store only)
This commit is contained in:
28
.env.local
28
.env.local
@@ -1,6 +1,26 @@
|
|||||||
# Local Development MinIO Configuration
|
# Local Development Configuration
|
||||||
# Copy to .env.local and update with your MinIO credentials
|
# Copy to .env.local and fill in your values.
|
||||||
|
|
||||||
# MinIO credentials (get from your production server)
|
# ── MinIO credentials ─────────────────────────────────────────────────────────
|
||||||
MINIO_KEY=secret_key
|
MINIO_KEY=your_minio_root_user
|
||||||
MINIO_SECRET=your_minio_root_password
|
MINIO_SECRET=your_minio_root_password
|
||||||
|
|
||||||
|
# ── SSH tunnel (remote DB) ────────────────────────────────────────────────────
|
||||||
|
# Only needed when starting with --profile ssh-db.
|
||||||
|
# NOTE: no inline comments allowed after values — Docker reads them literally.
|
||||||
|
|
||||||
|
SSH_HOST=136.114.183.15
|
||||||
|
SSH_PORT=22
|
||||||
|
SSH_USER=webmaster
|
||||||
|
# Must be an absolute path — Docker Compose does NOT expand ~
|
||||||
|
SSH_KEY_PATH=/Users/webmaster/.ssh/id_ed25519_crew_webmaster
|
||||||
|
|
||||||
|
SSH_DB_REMOTE_HOST=127.0.0.1
|
||||||
|
SSH_DB_REMOTE_PORT=3306
|
||||||
|
|
||||||
|
# Tell the app to route DB traffic through the tunnel container:
|
||||||
|
DB_HOST=db-tunnel
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=custom_designs
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=VeryStrongRootPass2025!
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
# Local Development MinIO Configuration
|
# Local Development Configuration
|
||||||
# Copy to .env.local and update with your MinIO credentials
|
# Copy to .env.local and fill in your values.
|
||||||
|
|
||||||
# MinIO credentials (get from your production server)
|
# ── MinIO credentials ─────────────────────────────────────────────────────────
|
||||||
MINIO_KEY=your_minio_root_user
|
MINIO_KEY=your_minio_root_user
|
||||||
MINIO_SECRET=your_minio_root_password
|
MINIO_SECRET=your_minio_root_password
|
||||||
|
|
||||||
|
# ── SSH tunnel (remote DB) ────────────────────────────────────────────────────
|
||||||
|
# Only needed when starting with --profile ssh-db.
|
||||||
|
# IMPORTANT: no inline comments after values — Docker Compose reads them literally.
|
||||||
|
# IMPORTANT: SSH_KEY_PATH must be an absolute path — ~ is NOT expanded by Docker Compose.
|
||||||
|
#
|
||||||
|
SSH_HOST=your.server.ip.or.hostname
|
||||||
|
SSH_PORT=22
|
||||||
|
SSH_USER=root
|
||||||
|
SSH_KEY_PATH=/absolute/path/to/your/private/key
|
||||||
|
#
|
||||||
|
SSH_DB_REMOTE_HOST=127.0.0.1 # DB host as seen from the SSH server
|
||||||
|
SSH_DB_REMOTE_PORT=3306 # DB port as seen from the SSH server
|
||||||
|
#
|
||||||
|
# Tell the app to route DB traffic through the tunnel container:
|
||||||
|
DB_HOST=db-tunnel
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=your_remote_db_name
|
||||||
|
DB_USERNAME=your_remote_db_user
|
||||||
|
DB_PASSWORD=your_remote_db_password
|
||||||
|
|||||||
17
Makefile
Normal file
17
Makefile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
COMPOSE_LOCAL = docker compose -f docker-compose.local.yml
|
||||||
|
|
||||||
|
# ── Local stack (local MariaDB) ───────────────────────────────────────────────
|
||||||
|
up:
|
||||||
|
$(COMPOSE_LOCAL) up --build
|
||||||
|
|
||||||
|
down:
|
||||||
|
$(COMPOSE_LOCAL) down
|
||||||
|
|
||||||
|
# ── Local stack with SSH tunnel to remote DB ──────────────────────────────────
|
||||||
|
up-ssh:
|
||||||
|
$(COMPOSE_LOCAL) --env-file .env.local --profile ssh-db up --build
|
||||||
|
|
||||||
|
down-ssh:
|
||||||
|
$(COMPOSE_LOCAL) --env-file .env.local --profile ssh-db down
|
||||||
|
|
||||||
|
.PHONY: up down up-ssh down-ssh
|
||||||
@@ -107,6 +107,63 @@ Then update `filesystems.php`:
|
|||||||
'privateKey' => '/var/keys/root.pem',
|
'privateKey' => '/var/keys/root.pem',
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development — Remote DB via SSH Tunnel
|
||||||
|
|
||||||
|
Use the `ssh-db` profile to connect the local app to a **remote database** through an SSH tunnel, authenticated with a private key.
|
||||||
|
|
||||||
|
### 1. Configure `.env.local`
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# SSH jump host
|
||||||
|
SSH_HOST=your.server.ip.or.hostname
|
||||||
|
SSH_PORT=22
|
||||||
|
SSH_USER=root
|
||||||
|
SSH_KEY_PATH=~/.ssh/id_rsa # path to your private key on the Mac host
|
||||||
|
|
||||||
|
# DB endpoint as seen from the SSH server
|
||||||
|
SSH_DB_REMOTE_HOST=127.0.0.1
|
||||||
|
SSH_DB_REMOTE_PORT=3306
|
||||||
|
|
||||||
|
# Tell the app to route through the tunnel container
|
||||||
|
DB_HOST=db-tunnel
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=your_remote_db_name
|
||||||
|
DB_USERNAME=your_remote_db_user
|
||||||
|
DB_PASSWORD=your_remote_db_password
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start the stack with the tunnel profile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.local.yml --profile ssh-db up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts a `db-tunnel` sidecar container (Alpine + openssh-client) that creates:
|
||||||
|
|
||||||
|
```
|
||||||
|
Mac host → [SSH tunnel] → SSH_HOST → DB (SSH_DB_REMOTE_HOST:SSH_DB_REMOTE_PORT)
|
||||||
|
```
|
||||||
|
|
||||||
|
The app container connects to `db-tunnel:3306`, which forwards all traffic through the encrypted tunnel.
|
||||||
|
|
||||||
|
### 3. Key requirements
|
||||||
|
|
||||||
|
| Requirement | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Key format | OpenSSH (`id_rsa`, `id_ed25519`) — **not** `.ppk` |
|
||||||
|
| Key permissions | `chmod 600 ~/.ssh/id_rsa` |
|
||||||
|
| SSH server | Authorised key must be in `~/.ssh/authorized_keys` on `SSH_HOST` |
|
||||||
|
|
||||||
|
> **Tip:** If your key is in PuTTY (`.ppk`) format, convert it first:
|
||||||
|
> ```bash
|
||||||
|
> puttygen root.ppk -O private-openssh -o ~/.ssh/id_rsa
|
||||||
|
> chmod 600 ~/.ssh/id_rsa
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security Best Practices
|
## Security Best Practices
|
||||||
|
|
||||||
✅ **DO:**
|
✅ **DO:**
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Local development stack
|
||||||
|
#
|
||||||
|
# Default (local MariaDB):
|
||||||
|
# docker compose -f docker-compose.local.yml up --build
|
||||||
|
#
|
||||||
|
# Remote DB via SSH private-key tunnel:
|
||||||
|
# 1. Set SSH_HOST, SSH_USER, SSH_KEY_PATH, SSH_DB_REMOTE_HOST,
|
||||||
|
# SSH_DB_REMOTE_PORT (and DB_* creds) in .env.local
|
||||||
|
# 2. docker compose -f docker-compose.local.yml --profile ssh-db up --build
|
||||||
|
# The app will talk to db-tunnel (port 3306) instead of the local db.
|
||||||
|
#
|
||||||
|
# App: http://localhost:8082
|
||||||
|
# phpMyAdmin: http://localhost:8083
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: mariadb:10.6
|
image: mariadb:10.6
|
||||||
@@ -29,11 +45,7 @@ services:
|
|||||||
- APP_DEBUG=true
|
- APP_DEBUG=true
|
||||||
- APP_URL=http://localhost:8082
|
- APP_URL=http://localhost:8082
|
||||||
- DB_CONNECTION=mysql
|
- DB_CONNECTION=mysql
|
||||||
- DB_HOST=db
|
- DB_PORT=${DB_PORT:-3306}
|
||||||
- DB_PORT=3306
|
|
||||||
- DB_DATABASE=crewsportswear
|
|
||||||
- DB_USERNAME=crewsportswear
|
|
||||||
- DB_PASSWORD=secret
|
|
||||||
- PROD_PRIVATE=http://localhost:8082
|
- PROD_PRIVATE=http://localhost:8082
|
||||||
- IMAGES_URL=http://localhost:8082
|
- IMAGES_URL=http://localhost:8082
|
||||||
- UPLOAD_URL=http://localhost:8082/uploads/
|
- UPLOAD_URL=http://localhost:8082/uploads/
|
||||||
@@ -56,15 +68,65 @@ services:
|
|||||||
- MINIO_REGION=us-east-1
|
- MINIO_REGION=us-east-1
|
||||||
- MINIO_USE_PATH_STYLE=false
|
- MINIO_USE_PATH_STYLE=false
|
||||||
- MINIO_URL=https://minio.crewsportswear.app
|
- MINIO_URL=https://minio.crewsportswear.app
|
||||||
|
# DB_HOST defaults to local container; set to db-tunnel in .env.local for SSH mode
|
||||||
|
- DB_HOST=${DB_HOST:-db}
|
||||||
|
- DB_DATABASE=${DB_DATABASE:-crewsportswear}
|
||||||
|
- DB_USERNAME=${DB_USERNAME:-crewsportswear}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-secret}
|
||||||
|
env_file:
|
||||||
|
- path: .env.local
|
||||||
|
required: false
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html
|
- ./:/var/www/html
|
||||||
- ./storage:/var/www/html/storage
|
- ./storage:/var/www/html/storage
|
||||||
- ./public/uploads:/var/www/html/public/uploads
|
- ./public/uploads:/var/www/html/public/uploads
|
||||||
|
# Keep the vendor/ directory from the image — prevents the bind-mount
|
||||||
|
# from wiping out the composer install done during docker build.
|
||||||
|
- vendor_local:/var/www/html/vendor
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
networks:
|
networks:
|
||||||
- crewsportswear-local
|
- crewsportswear-local
|
||||||
|
|
||||||
|
# ── SSH tunnel to a remote database ────────────────────────────────────────
|
||||||
|
# Activated only with: --profile ssh-db
|
||||||
|
# Requires SSH_HOST, SSH_USER, SSH_KEY_PATH (and optionally SSH_PORT,
|
||||||
|
# SSH_DB_REMOTE_HOST, SSH_DB_REMOTE_PORT) set in .env.local.
|
||||||
|
db-tunnel:
|
||||||
|
profiles:
|
||||||
|
- ssh-db
|
||||||
|
image: alpine:3.19
|
||||||
|
container_name: crewsportswear_db_tunnel
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- SSH_HOST=${SSH_HOST}
|
||||||
|
- SSH_PORT=${SSH_PORT:-22}
|
||||||
|
- SSH_USER=${SSH_USER:-root}
|
||||||
|
- SSH_DB_REMOTE_HOST=${SSH_DB_REMOTE_HOST:-127.0.0.1}
|
||||||
|
- SSH_DB_REMOTE_PORT=${SSH_DB_REMOTE_PORT:-3306}
|
||||||
|
volumes:
|
||||||
|
# Mount your private key read-only; path configured in .env.local
|
||||||
|
- ${SSH_KEY_PATH:-~/.ssh/id_rsa}:/ssh/id_rsa:ro
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
apk add --no-cache openssh-client
|
||||||
|
cp /ssh/id_rsa /tmp/id_rsa
|
||||||
|
chmod 600 /tmp/id_rsa
|
||||||
|
echo "Starting SSH tunnel to $$SSH_HOST..."
|
||||||
|
exec ssh -N \
|
||||||
|
-o StrictHostKeyChecking=no \
|
||||||
|
-o ServerAliveInterval=30 \
|
||||||
|
-o ServerAliveCountMax=3 \
|
||||||
|
-o ExitOnForwardFailure=yes \
|
||||||
|
-i /tmp/id_rsa \
|
||||||
|
-L "0.0.0.0:3306:$$SSH_DB_REMOTE_HOST:$$SSH_DB_REMOTE_PORT" \
|
||||||
|
-p "$$SSH_PORT" \
|
||||||
|
"$$SSH_USER@$$SSH_HOST"
|
||||||
|
networks:
|
||||||
|
- crewsportswear-local
|
||||||
|
|
||||||
phpmyadmin:
|
phpmyadmin:
|
||||||
image: arm64v8/phpmyadmin
|
image: arm64v8/phpmyadmin
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
@@ -87,3 +149,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
|
vendor_local:
|
||||||
|
|||||||
@@ -13,5 +13,11 @@ mkdir -p bootstrap/cache
|
|||||||
chown -R www-data:www-data storage bootstrap/cache
|
chown -R www-data:www-data storage bootstrap/cache
|
||||||
chmod -R 775 storage bootstrap/cache
|
chmod -R 775 storage bootstrap/cache
|
||||||
|
|
||||||
|
# Install/update Composer dependencies if vendor is missing or composer.json changed
|
||||||
|
if [ ! -f vendor/autoload.php ]; then
|
||||||
|
echo "vendor/autoload.php not found — running composer install..."
|
||||||
|
composer install --no-interaction --prefer-dist
|
||||||
|
fi
|
||||||
|
|
||||||
# Execute the main command
|
# Execute the main command
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -127,6 +127,126 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Category nav ──────────────────────────────────────────────────────── */
|
||||||
|
.cat-nav {
|
||||||
|
background: #f8f8f8;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.cat-nav ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cat-nav > ul > li {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cat-nav > ul > li > a {
|
||||||
|
display: block;
|
||||||
|
padding: 14px 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #222;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: border-color .2s, color .2s;
|
||||||
|
}
|
||||||
|
.cat-nav > ul > li > a:hover,
|
||||||
|
.cat-nav > ul > li > a.active {
|
||||||
|
color: #4B8E4B;
|
||||||
|
border-bottom-color: #4B8E4B;
|
||||||
|
}
|
||||||
|
/* sub-category dropdown */
|
||||||
|
.cat-nav .sub-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-top: 3px solid #4B8E4B;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,.12);
|
||||||
|
z-index: 999;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.cat-nav > ul > li:hover .sub-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.cat-nav .sub-menu li a {
|
||||||
|
display: block;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cat-nav .sub-menu li a:hover,
|
||||||
|
.cat-nav .sub-menu li a.active {
|
||||||
|
background: #f0f9f0;
|
||||||
|
color: #4B8E4B;
|
||||||
|
}
|
||||||
|
.cat-count {
|
||||||
|
display: inline-block;
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── League / conference pill filter (sub-sub-category) ─────────────────── */
|
||||||
|
.league-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 0 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.league-filter .lf-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #888;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
margin-right: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.league-filter a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
transition: background .2s, color .2s, border-color .2s;
|
||||||
|
}
|
||||||
|
.league-filter a:hover {
|
||||||
|
background: #e0f2e0;
|
||||||
|
color: #4B8E4B;
|
||||||
|
border-color: #4B8E4B;
|
||||||
|
}
|
||||||
|
.league-filter a.active {
|
||||||
|
background: #4B8E4B;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4B8E4B;
|
||||||
|
}
|
||||||
|
[v-cloak] { display: none; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -150,16 +270,96 @@
|
|||||||
<h2>FEATURED PRODUCTS</h2>
|
<h2>FEATURED PRODUCTS</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
|
{{-- ── Vue category + product grid ─────────────────────────────────────── --}}
|
||||||
|
<script>
|
||||||
|
window._tsProducts = {!! json_encode(
|
||||||
|
collect($product_array)
|
||||||
|
->where('PrivacyStatus', 'public')
|
||||||
|
->map(function($p) use ($thumbnails) {
|
||||||
|
$thumbList = isset($thumbnails) ? $thumbnails : [];
|
||||||
|
$thumb = collect($thumbList)->filter(function($t) use ($p) {
|
||||||
|
return $t['product_id'] == $p->Id;
|
||||||
|
})->first();
|
||||||
|
return [
|
||||||
|
'id' => $p->Id,
|
||||||
|
'name' => $p->ProductName,
|
||||||
|
'price' => $p->ProductPrice,
|
||||||
|
'url' => $p->ProductURL,
|
||||||
|
'img' => $thumb ? $thumb['thumb'] : 'product-image-placeholder.png',
|
||||||
|
'folder'=> $thumb ? $thumb['folder'] : '',
|
||||||
|
];
|
||||||
|
})->values()->toArray(),
|
||||||
|
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT
|
||||||
|
) !!};
|
||||||
|
window._tsStore = {
|
||||||
|
url : {!! json_encode($store_array[0]->StoreUrl, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) !!},
|
||||||
|
currency : {!! json_encode($store_array[0]->StoreCurrency, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) !!},
|
||||||
|
minoBase : {!! json_encode(rtrim(config('filesystems.disks.minio.url', ''), '/') . '/' . env('MINIO_BUCKET', 'crewsportswear') . '/', JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) !!},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="ts-app" v-cloak>
|
||||||
|
|
||||||
|
{{-- ── Category nav ── --}}
|
||||||
|
<nav class="cat-nav" v-if="categories.length > 0">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="#" :class="{active: activeCategory===null && activeSubCategory===null}"
|
||||||
|
@click.prevent="activeCategory=null; activeSubCategory=null">
|
||||||
|
All
|
||||||
|
<span class="cat-count">@{{ totalPublic }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li v-for="cat in categories" :key="cat.name">
|
||||||
|
<a href="#"
|
||||||
|
:class="{active: activeCategory===cat.name}"
|
||||||
|
@click.prevent="selectCategory(cat.name)">
|
||||||
|
@{{ cat.name }}
|
||||||
|
<span class="cat-count">@{{ cat.count }}</span>
|
||||||
|
</a>
|
||||||
|
<ul class="sub-menu" v-if="cat.subs.length > 0">
|
||||||
|
<li v-for="sub in cat.subs" :key="sub.name">
|
||||||
|
<a href="#"
|
||||||
|
:class="{active: activeCategory===cat.name && activeSubCategory===sub.name}"
|
||||||
|
@click.prevent="selectSub(cat.name, sub.name)">
|
||||||
|
@{{ sub.name }}
|
||||||
|
<span class="cat-count">@{{ sub.count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{{-- ── League / conference pill filter ── --}}
|
||||||
|
<div class="league-filter" v-if="isLeagueStore && activeCategory !== null && leagues.length > 0">
|
||||||
|
<span class="lf-label">League / Conf:</span>
|
||||||
|
<a href="#"
|
||||||
|
:class="{active: activeLeague === null}"
|
||||||
|
@click.prevent="activeLeague = null">All</a>
|
||||||
|
<a href="#"
|
||||||
|
v-for="lg in leagues" :key="lg.name"
|
||||||
|
:class="{active: activeLeague === lg.name}"
|
||||||
|
@click.prevent="activeLeague = lg.name">
|
||||||
|
@{{ lg.name }}
|
||||||
|
<span class="cat-count">@{{ lg.count }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ── Announcements (kept outside loop) ── --}}
|
||||||
@if ($announcement->IsActive)
|
@if ($announcement->IsActive)
|
||||||
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<p><b>Shop Announcements:</b></p>
|
<p><b>Shop Announcements:</b></p>
|
||||||
{!! nl2br(e($announcement->Announcement)) !!}
|
{!! nl2br(e($announcement->Announcement)) !!}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@if($store_array[0]->Id == 174 || $store_array[0]->Id == 175 || $store_array[0]->Id == 178 || $store_array[0]->Id == 179 || $store_array[0]->Id == 177 || $store_array[0]->Id == 189 || $store_array[0]->Id == 176 || $store_array[0]->Id == 190 || $store_array[0]->Id == 191 || $store_array[0]->Id == 192 || $store_array[0]->Id == 194)
|
@if($store_array[0]->Id == 174 || $store_array[0]->Id == 175 || $store_array[0]->Id == 178 || $store_array[0]->Id == 179 || $store_array[0]->Id == 177 || $store_array[0]->Id == 189 || $store_array[0]->Id == 176 || $store_array[0]->Id == 190 || $store_array[0]->Id == 191 || $store_array[0]->Id == 192 || $store_array[0]->Id == 194)
|
||||||
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<p><b>Please read:</b></p>
|
<p><b>Please read:</b></p>
|
||||||
@@ -172,58 +372,175 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@else
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<p><b>Please read:</b></p>
|
|
||||||
1. Items purchased are made on demand. Orders will be shipped based on dates set by your store administrator.<br>
|
|
||||||
2. Store payments are processed through PayPal. A PayPal account is not required to make a purchase.<br>
|
|
||||||
@if($store_array[0]->Id == 222)
|
|
||||||
3. Orders will be batch processed on a monthly basis, please allow 4-6 weeks for delivery.<br>
|
|
||||||
@else
|
|
||||||
3. Orders will be batch processed on a weekly basis, please allow 2-3 weeks for delivery.<br>
|
|
||||||
@endif
|
|
||||||
4. We are currently only shipping to US locations. For international orders, please contact <b>orders@crewsportswear.com</b> if you'd like to place an order.<br>
|
|
||||||
5. Expect shipping delays due to COVID-19.<br>
|
|
||||||
6. All sales are final. No returns or exchanges will be accepted.<br><br>
|
|
||||||
|
|
||||||
<b>DISCLAIMER:</b> Masks and gaiters sold by Crew Sportswear are not intended for medical use. Crew Sportswear does not make any medical or health claims.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<!-- BEGIN PRODUCTS -->
|
|
||||||
|
|
||||||
@foreach($product_array as $i => $product)
|
{{-- ── Product grid ── --}}
|
||||||
@if($product->PrivacyStatus == "public")
|
<div class="row">
|
||||||
@foreach($thumbnails as $t => $thumb)
|
<div class="col-md-12 text-center" v-if="filtered.length === 0">
|
||||||
@if($thumb['product_id'] == $product->Id)
|
<p style="margin:40px 0;color:#888;">No products found in this category.</p>
|
||||||
@define $storeFolder = $thumb['folder']
|
</div>
|
||||||
@define $filename = $thumb['thumb']
|
<div class="col-md-3 col-sm-6" v-for="p in filtered" :key="p.id">
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
|
|
||||||
<div class="col-md-3 col-sm-6">
|
|
||||||
<span class="thumbnail">
|
<span class="thumbnail">
|
||||||
<a href="{{ url('teamstore') }}/{{ $store_array[0]->StoreUrl }}/product/{{ $product->ProductURL }}">
|
<a :href="productUrl(p)">
|
||||||
<img style="height: 201.84px;" src="{{ minio_url('images/' . $filename) }}" alt="{{ $product->ProductName }}" >
|
<img style="height:201.84px;" :src="imgUrl(p)" :alt="p.name">
|
||||||
</a>
|
</a>
|
||||||
<h4 class="text-center product-name-holder">{{ $product->ProductName }}</h4>
|
<h4 class="text-center product-name-holder">@{{ p.name }}</h4>
|
||||||
<hr class="line">
|
<hr class="line">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-7 col-sm-7">
|
<div class="col-md-7 col-sm-7">
|
||||||
<p class="price">{{ $product->ProductPrice }} <small style="font-size: 15px;"> {{ $store_array[0]->StoreCurrency }}</small></p>
|
<p class="price">@{{ p.price }} <small style="font-size:15px;">@{{ store.currency }}</small></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5 col-sm-5">
|
<div class="col-md-5 col-sm-5">
|
||||||
<a href="{{ url('teamstore') }}/{{ $store_array[0]->StoreUrl }}/product/{{ $product->ProductURL }}" class="btn btn-success right" > View Details</a>
|
<a :href="productUrl(p)" class="btn btn-success right">View Details</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
<!-- END PRODUCTS -->
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div> <!-- cotainer -->
|
|
||||||
|
</div>{{-- /ts-app --}}
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// ── sport & item-type keyword maps ──────────────────────────────────
|
||||||
|
const SPORTS = [
|
||||||
|
'Basketball','Football','Soccer','Baseball','Softball',
|
||||||
|
'Volleyball','Hockey','Lacrosse','Wrestling','Tennis',
|
||||||
|
'Swimming','Track','Cross Country','Golf','Cheerleading',
|
||||||
|
'Dance','Rugby','Bowling','Gymnastics','Cycling',
|
||||||
|
];
|
||||||
|
const ITEMS = [
|
||||||
|
'Jersey','T-Shirt','Tee','Hoodie','Sweatshirt','Jacket',
|
||||||
|
'Shorts','Pants','Tank','Top','Pullover','Zip-Up',
|
||||||
|
'Hat','Cap','Beanie','Polo','Uniform','Warmup','Pinnie',
|
||||||
|
];
|
||||||
|
// ── league / conference keyword map ──────────────────────────────────
|
||||||
|
// Order matters: more specific phrases first
|
||||||
|
const LEAGUES = [
|
||||||
|
{ key: 'NBA', terms: ['nba'] },
|
||||||
|
{ key: 'NFL', terms: ['nfl'] },
|
||||||
|
{ key: 'MLB', terms: ['mlb'] },
|
||||||
|
{ key: 'NHL', terms: ['nhl'] },
|
||||||
|
{ key: 'MLS', terms: ['mls'] },
|
||||||
|
{ key: 'WNBA', terms: ['wnba'] },
|
||||||
|
{ key: 'Big Ten', terms: ['big ten','big10','big 10'] },
|
||||||
|
{ key: 'ACC', terms: ['acc'] },
|
||||||
|
{ key: 'SEC', terms: ['sec'] },
|
||||||
|
{ key: 'Big 12', terms: ['big 12','big12'] },
|
||||||
|
{ key: 'Pac-12', terms: ['pac-12','pac12','pac 12'] },
|
||||||
|
{ key: 'AAC', terms: ['aac'] },
|
||||||
|
{ key: 'CUSA', terms: ['cusa','c-usa'] },
|
||||||
|
{ key: 'MAC', terms: ['\bmac\b'] },
|
||||||
|
{ key: 'Sun Belt',terms: ['sun belt'] },
|
||||||
|
{ key: 'Mountain West', terms: ['mountain west','mwc'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function classify(name) {
|
||||||
|
const n = name.toLowerCase();
|
||||||
|
const sport = SPORTS.find(s => n.includes(s.toLowerCase())) || 'Other';
|
||||||
|
const item = ITEMS.find(i => n.includes(i.toLowerCase())) || 'Other';
|
||||||
|
let league = null;
|
||||||
|
for (const lg of LEAGUES) {
|
||||||
|
if (lg.terms.some(t => {
|
||||||
|
try { return new RegExp(t, 'i').test(n); } catch(e) { return n.includes(t); }
|
||||||
|
})) {
|
||||||
|
league = lg.key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sport, item, league };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createApp } = Vue;
|
||||||
|
const store = window._tsStore || { url:'', currency:'', minoBase:'' };
|
||||||
|
const allProducts = Array.isArray(window._tsProducts) ? window._tsProducts : [];
|
||||||
|
|
||||||
|
createApp({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
products : allProducts,
|
||||||
|
store : store,
|
||||||
|
activeCategory : null,
|
||||||
|
activeSubCategory: null,
|
||||||
|
activeLeague : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
totalPublic() {
|
||||||
|
return this.products.length;
|
||||||
|
},
|
||||||
|
isLeagueStore() {
|
||||||
|
return this.store.url === 'hi-five-franchise-store';
|
||||||
|
},
|
||||||
|
// Build [ { name:'Basketball', count:N, subs:[{name:'Jersey',count:N},...] }, ... ]
|
||||||
|
categories() {
|
||||||
|
const map = {};
|
||||||
|
this.products.forEach(p => {
|
||||||
|
const { sport, item } = classify(p.name);
|
||||||
|
if (!map[sport]) map[sport] = {};
|
||||||
|
map[sport][item] = (map[sport][item] || 0) + 1;
|
||||||
|
});
|
||||||
|
return Object.entries(map)
|
||||||
|
.sort((a,b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([name, subs]) => ({
|
||||||
|
name,
|
||||||
|
count: Object.values(subs).reduce((a,b) => a+b, 0),
|
||||||
|
subs: Object.entries(subs)
|
||||||
|
.sort((a,b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([s, count]) => ({ name: s, count }))
|
||||||
|
.filter(s => s.name !== 'Other' || Object.keys(subs).length === 1),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
// Available leagues for the currently active category (+ optional sub)
|
||||||
|
// Only computed for hi-five-franchise-store
|
||||||
|
leagues() {
|
||||||
|
if (!this.isLeagueStore) return [];
|
||||||
|
const map = {};
|
||||||
|
this.products.forEach(p => {
|
||||||
|
const { sport, item, league } = classify(p.name);
|
||||||
|
if (!league) return;
|
||||||
|
if (this.activeCategory && sport !== this.activeCategory) return;
|
||||||
|
if (this.activeSubCategory && item !== this.activeSubCategory) return;
|
||||||
|
map[league] = (map[league] || 0) + 1;
|
||||||
|
});
|
||||||
|
return Object.entries(map)
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([name, count]) => ({ name, count }));
|
||||||
|
},
|
||||||
|
filtered() {
|
||||||
|
if (!this.activeCategory && !this.activeLeague) return this.products;
|
||||||
|
return this.products.filter(p => {
|
||||||
|
const { sport, item, league } = classify(p.name);
|
||||||
|
if (this.activeCategory && sport !== this.activeCategory) return false;
|
||||||
|
if (this.activeSubCategory && item !== this.activeSubCategory) return false;
|
||||||
|
if (this.isLeagueStore && this.activeLeague && league !== this.activeLeague) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
selectCategory(cat) {
|
||||||
|
this.activeCategory = cat;
|
||||||
|
this.activeSubCategory = null;
|
||||||
|
this.activeLeague = null;
|
||||||
|
},
|
||||||
|
selectSub(cat, sub) {
|
||||||
|
this.activeCategory = cat;
|
||||||
|
this.activeSubCategory = sub;
|
||||||
|
this.activeLeague = null;
|
||||||
|
},
|
||||||
|
productUrl(p) {
|
||||||
|
return '/teamstore/' + store.url + '/product/' + p.url;
|
||||||
|
},
|
||||||
|
imgUrl(p) {
|
||||||
|
return store.minoBase + 'images/' + p.img;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).mount('#ts-app');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</div>{{-- /container --}}
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
Reference in New Issue
Block a user