Compare commits
5 commits
main
...
addBgForTe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b9978c3a | ||
|
|
f8de715e2a | ||
|
|
047407b999 | ||
|
|
107f069d54 | ||
|
|
4e920abde1 |
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Bo;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Bo\Tenants\StoreTenantRequest;
|
||||
use App\Jobs\ShowroomExport;
|
||||
use App\Models\Font;
|
||||
use App\Models\Settings;
|
||||
use App\Models\Tenant;
|
||||
|
|
@ -134,4 +135,34 @@ class TenantController extends Controller
|
|||
$fontSerif->saveOrFail();
|
||||
$fontSansSerif->saveOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the tenant preview page (preview data are regenerated on each call).
|
||||
*
|
||||
* @return \Illuminate\Contracts\View\View
|
||||
*/
|
||||
public function previewApp()
|
||||
{
|
||||
(new ShowroomExport(tenant()->getTenantKey(), true))->handle();
|
||||
return view('bo.layouts.preview-app');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a file of the current tenant preview.
|
||||
*
|
||||
* @param string $path File path relative to the tenant preview folder.
|
||||
* @return \Symfony\Component\HttpFoundation\BinaryFileResponse
|
||||
*/
|
||||
public function previewData(string $path)
|
||||
{
|
||||
$base = \realpath(\storage_path('app/preview/' . tenant()->getTenantKey()));
|
||||
$file = $base ? \realpath($base . '/' . $path) : false;
|
||||
|
||||
abort_unless(
|
||||
$file && \is_file($file) && \str_starts_with($file, $base . DIRECTORY_SEPARATOR),
|
||||
404
|
||||
);
|
||||
|
||||
return response()->file($file);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class UpdateSettingsRequest extends FormRequest
|
|||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->mergePicture('logo');
|
||||
$this->mergePicture('background_image');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,11 +48,13 @@ class UpdateSettingsRequest extends FormRequest
|
|||
'footer_phrase.*' => 'required|min:2|max:110',
|
||||
'color' => ['required', new Hex()],
|
||||
'home_timeout' => 'required|numeric|min:20|max:900',
|
||||
'background_color' => ['nullable', new Hex()],
|
||||
];
|
||||
|
||||
return \array_merge(
|
||||
$rules,
|
||||
$this->pictureRules('logo', 420, 500, 420, 500, false)
|
||||
$this->pictureRules('logo', 420, 500, 420, 500, false),
|
||||
$this->pictureRules('background_image', 1080, 1920, 1080, 1920, true, 2048)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +72,8 @@ class UpdateSettingsRequest extends FormRequest
|
|||
'home_timeout' => \trans('validation.custom.home_timeout'),
|
||||
'sub_title.*' => \trans('validation.custom.sub_title'),
|
||||
'footer_phrase.*' => \trans('validation.custom.footer_phrase'),
|
||||
'background_color' => \trans('validation.custom.background_color'),
|
||||
'background_image' => \trans('validation.custom.background_image'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,20 +60,30 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
|
|||
*/
|
||||
public int $uniqueFor = 3600;
|
||||
|
||||
/**
|
||||
* Files in DB and missing from the disk
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
private array $missingFiles = [];
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @param string $tenantId
|
||||
* @param string $tenantId
|
||||
* @param boolean $preview Write the export in the tenant preview folder instead of a zip file.
|
||||
*
|
||||
* @throws RuntimeException If there's no Tenant for the given id.
|
||||
*/
|
||||
public function __construct(string $tenantId)
|
||||
public function __construct(string $tenantId, private bool $preview = false)
|
||||
{
|
||||
if (!Tenant::query()->where('id', $tenantId)->first()) {
|
||||
throw new RuntimeException(\sprintf('No Tenant found for id : %s', $tenantId));
|
||||
}
|
||||
$this->exportFolder = \storage_path($this->exportFolder);
|
||||
$this->tempFolder = \storage_path($this->tempFolder);
|
||||
$this->tempFolder = $preview
|
||||
? \storage_path("app/preview/{$tenantId}")
|
||||
: \storage_path($this->tempFolder);
|
||||
$this->tenantId = $tenantId;
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +105,9 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
|
|||
|
||||
// * Make json File add add to files to zip.
|
||||
File::put($dataFilePath, json_encode($fileContent), true);
|
||||
if ($this->preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File::exists($this->exportFolder)) {
|
||||
File::makeDirectory($this->exportFolder, 0700, true);
|
||||
|
|
@ -184,7 +197,7 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
|
|||
*
|
||||
* @param array<string,array<int,\App\Models\Brand>|array<int,\App\Models\Font>> $data
|
||||
* @return array{
|
||||
* settings: string,
|
||||
* settings: array<int, string>,
|
||||
* brands: array<int, string>,
|
||||
* fonts: array<int, string>,
|
||||
* ranges: array<int, string>,
|
||||
|
|
@ -206,8 +219,9 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
|
|||
])->collect()->groupBy($groupBy)
|
||||
])->collect();
|
||||
|
||||
/** @var string $logo */
|
||||
$logo = Arr::get($this->getSettings(), 'logo');
|
||||
$settings = $this->getSettings();
|
||||
/** @var array<int, string> $settingsFiles */
|
||||
$settingsFiles = \array_values(\array_filter([$settings->logo, $settings->background_image]));
|
||||
/** @var array<string> $fonts */
|
||||
$fonts = collect(Arr::get($data, 'fonts'))->pluck('font')->filter()->all();
|
||||
/** @var \Illuminate\Support\Collection<int, string> $brandList */
|
||||
|
|
@ -219,7 +233,7 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
|
|||
$productList = $items->pluck(1)->map(fn(Collection $ranges) => $ranges
|
||||
->map(fn(Collection $range) => $range->pluck(1)))->flatten();
|
||||
return [
|
||||
'settings' => $logo,
|
||||
'settings' => $settingsFiles,
|
||||
'brands' => $brandList->all(),
|
||||
'fonts' => $fonts,
|
||||
'ranges' => $rangeList->all(),
|
||||
|
|
@ -242,6 +256,12 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
|
|||
\pathinfo($settings->logo, \PATHINFO_BASENAME),
|
||||
"pictures/{$tableName}/"
|
||||
);
|
||||
if ($settings->background_image) {
|
||||
$settings->background_image = Str::start(
|
||||
\pathinfo($settings->background_image, \PATHINFO_BASENAME),
|
||||
"pictures/{$tableName}/"
|
||||
);
|
||||
}
|
||||
return [
|
||||
'version' => $this->getVersion(),
|
||||
'settings' => Arr::except($settings->toArray(), ['id']),
|
||||
|
|
@ -278,7 +298,7 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
|
|||
* Get the files that should be zipped
|
||||
*
|
||||
* @phpcs:ignore
|
||||
* @param array{settings: string,brands: array<int, string>,fonts: array<string>,ranges: array<int, string>,products: array<int, string>} $filesToZip
|
||||
* @param array{settings: array<int, string>,brands: array<int, string>,fonts: array<string>,ranges: array<int, string>,products: array<int, string>} $filesToZip
|
||||
* @return array<string>
|
||||
* @throws \RuntimeException If temp folder caanot be cleared in order to work.
|
||||
*/
|
||||
|
|
@ -293,25 +313,36 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
|
|||
throw new \RuntimeException("{$this->tempFolder} is not empty, refuse to prepare export");
|
||||
}
|
||||
|
||||
$copyPath = fn(string $targetPath) => function (string $path) use ($targetPath): string {
|
||||
$targetFilePath = Str::of($targetPath)->append('/')->append(\pathinfo($path, \PATHINFO_BASENAME));
|
||||
File::copy(\public_path($path), $targetFilePath);
|
||||
return $targetFilePath;
|
||||
};
|
||||
$toCopy = ['settings', 'brands', 'ranges', 'products'];
|
||||
$toCopy = ['settings', 'brands', 'ranges', 'products'];
|
||||
foreach ($toCopy as $element) {
|
||||
$targetPath = Str::of($this->tempFolder)->append("/pictures/{$element}");
|
||||
$targetPath = "{$this->tempFolder}/pictures/{$element}";
|
||||
File::makeDirectory($targetPath, 0700, true);
|
||||
$newPaths[$element] = collect($filesToZip[$element])->map($copyPath($targetPath))->all();
|
||||
$newPaths[$element] = collect($filesToZip[$element])
|
||||
->map(fn(?string $path) => $this->copyToFolder($path, $targetPath))
|
||||
->filter()
|
||||
->all();
|
||||
}
|
||||
// * 'fonts'
|
||||
$targetPath = Str::of($this->tempFolder)->append("/fonts");
|
||||
$targetPath = "{$this->tempFolder}/fonts";
|
||||
File::makeDirectory($targetPath, 0700, true);
|
||||
$newPaths['fonts'] = collect($filesToZip['fonts'])->map(function ($path) use ($targetPath) {
|
||||
$targetFilePath = (string) $targetPath->append('/')->append(\pathinfo($path, \PATHINFO_BASENAME));
|
||||
File::copy(\public_path($path), $targetFilePath);
|
||||
return $targetFilePath;
|
||||
})->all();
|
||||
$newPaths['fonts'] = collect($filesToZip['fonts'])
|
||||
->map(fn(?string $path) => $this->copyToFolder($path, $targetPath))
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
if ($this->missingFiles !== []) {
|
||||
$message = \sprintf(
|
||||
'%d File(s) not found for the tenant %s : %s',
|
||||
\count($this->missingFiles),
|
||||
$this->tenantId,
|
||||
\implode(', ', $this->missingFiles)
|
||||
);
|
||||
if (!$this->preview) {
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
Log::warning($message);
|
||||
}
|
||||
|
||||
return collect($newPaths)->flatten()->all();
|
||||
}
|
||||
|
||||
|
|
@ -376,4 +407,26 @@ class ShowroomExport implements ShouldQueue, ShouldBeUnique
|
|||
->all()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a public file to the target folder, or mark it as missing.
|
||||
*
|
||||
* @param string|null $path File path public folder.
|
||||
* @param string $targetPath Folder.
|
||||
* @return string|null The copied file path, or null if the source file does not exist.
|
||||
*/
|
||||
private function copyToFolder(?string $path, string $targetPath): ?string
|
||||
{
|
||||
if (!$path) {
|
||||
return null;
|
||||
}
|
||||
$source = \public_path($path);
|
||||
if (!File::exists($source)) {
|
||||
$this->missingFiles[] = $path;
|
||||
return null;
|
||||
}
|
||||
$targetFilePath = $targetPath . '/' . \pathinfo($path, \PATHINFO_BASENAME);
|
||||
File::copy($source, $targetFilePath);
|
||||
return $targetFilePath;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ class Settings extends Model
|
|||
'footer_phrase',
|
||||
'color',
|
||||
'logo',
|
||||
'background_color',
|
||||
'background_image',
|
||||
'home_timeout',
|
||||
'tenant_id',
|
||||
];
|
||||
|
|
@ -75,10 +77,12 @@ class Settings extends Model
|
|||
});
|
||||
static::updated(function (self $model) {
|
||||
FileHelper::removeOldFile($model, 'logo');
|
||||
FileHelper::removeOldFile($model, 'background_image');
|
||||
self::resetCache($model->tenant_id);
|
||||
});
|
||||
static::deleted(function (self $model) {
|
||||
FileHelper::removeFile($model, 'logo');
|
||||
FileHelper::removeFile($model, 'background_image');
|
||||
self::removeFromCache($model->tenant_id);
|
||||
});
|
||||
}
|
||||
|
|
@ -98,6 +102,10 @@ class Settings extends Model
|
|||
if ($model->logo instanceof UploadedFile) {
|
||||
$model->logo = ShowroomFileHelper::storeFileWithUUIDName($model->logo, $model);
|
||||
}
|
||||
/** @phpstan-ignore-next-line */
|
||||
if ($model->background_image instanceof UploadedFile) {
|
||||
$model->background_image = ShowroomFileHelper::storeFileWithUUIDName($model->background_image, $model);
|
||||
}
|
||||
if (!$model->locales) {
|
||||
$model->locales = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->string('background_color', 7)->nullable()->after('color');
|
||||
$table->string('background_image')->nullable()->after('background_color');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->dropColumn(['background_color', 'background_image']);
|
||||
});
|
||||
}
|
||||
};
|
||||
978
package-lock.json
generated
12
package.json
|
|
@ -22,6 +22,7 @@
|
|||
"@fortawesome/vue-fontawesome": "^3.0.5",
|
||||
"@sipec/vue3-tags-input": "^3.0.4",
|
||||
"@types/bootstrap": "^5.2.7",
|
||||
"@types/konami-code-js": "^0.8.3",
|
||||
"@types/node": "^20.8.2",
|
||||
"@types/toastify-js": "^1.12.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
|
|
@ -31,14 +32,21 @@
|
|||
"@vue/compiler-sfc": "latest",
|
||||
"@vue/eslint-config-typescript": "latest",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.5.1",
|
||||
"bootstrap": "^5.3.2",
|
||||
"chart.js": "^4.4.0",
|
||||
"cropperjs": "^1.6.1",
|
||||
"dompurify": "^3.4.16",
|
||||
"eslint": "latest",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-plugin-vue": "latest",
|
||||
"flag-icons": "^7.5.0",
|
||||
"konami-code-js": "^0.8.3",
|
||||
"laravel-vite-plugin": "^0.8.1",
|
||||
"meow": "^9.0.0",
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^2.3.1",
|
||||
"postcss-discard-comments": "^6.0.0",
|
||||
"prettier": "latest",
|
||||
"print-js": "^1.6.0",
|
||||
|
|
@ -52,11 +60,13 @@
|
|||
"stylelint-config-standard-scss": "^11.1.0",
|
||||
"stylelint-scss": "^5.3.2",
|
||||
"sweetalert2": "^11.7.31",
|
||||
"swiper": "^11.2.10",
|
||||
"terser": "latest",
|
||||
"toastify-js": "^1.12.0",
|
||||
"ts-loader": "^9.3.1",
|
||||
"typescript": ">=3.3.1 <5.2.0",
|
||||
"typescript-eslint": "latest",
|
||||
"unplugin-auto-import": "^0.17.8",
|
||||
"vite": "^4.5.1",
|
||||
"vite-plugin-babel": "latest",
|
||||
"vite-plugin-checker": "latest",
|
||||
|
|
@ -64,7 +74,9 @@
|
|||
"vite-plugin-stylelint": "latest",
|
||||
"vue": "latest",
|
||||
"vue-eslint-parser": "^9.3.2",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-loader": "^17.3.1",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-tsc": "1.8.11"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
222
resources/expo/App.vue
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<template>
|
||||
<div
|
||||
class="app-component"
|
||||
:class="{ 'has-custom-background': Object.keys(backgroundStyle).length > 0 }"
|
||||
:style="backgroundStyle"
|
||||
@mousemove="setTimeout"
|
||||
@mousedown="setTimeout"
|
||||
@mouseup="setTimeout"
|
||||
@focus="setTimeout"
|
||||
@keyup="setTimeout"
|
||||
@keydown="setTimeout"
|
||||
@touchstart="setTimeout"
|
||||
>
|
||||
<template v-if="!loading">
|
||||
<AppHeader
|
||||
:active-brand="activeBrand"
|
||||
:active-range="activeRange"
|
||||
/>
|
||||
<div class="container-fluid overflow-auto p-0">
|
||||
<RouterView v-slot="{ Component, route: currentRoute }">
|
||||
<Transition
|
||||
name="page"
|
||||
mode="out-in"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
:key="currentRoute.fullPath"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
<NavigationMenu v-if="showNavigation" />
|
||||
<AppFooter
|
||||
v-if="showFooter"
|
||||
:active-brand="activeBrand"
|
||||
:active-range="activeRange"
|
||||
/>
|
||||
<ToastContainer />
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('loading') }}
|
||||
</template>
|
||||
<ChickenRun :run-animation="chickenRun" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
// import Modal from "bootstrap/js/dist/modal";
|
||||
import KonamiCode from "konami-code-js";
|
||||
// import Swal from "sweetalert2";
|
||||
import { defineComponent } from "vue";
|
||||
import type { RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import ToolboxHelper from "./classes/ToolboxHelper";
|
||||
import { useAppStore } from "@expo/stores/useAppStore";
|
||||
// import data from "data/test.json";
|
||||
import { dataUrl } from "@expo/composables/dataUrl";
|
||||
|
||||
export default defineComponent({
|
||||
name: "IndexPage",
|
||||
data(): {
|
||||
timeout: NodeJS.Timeout|undefined;
|
||||
loading: boolean;
|
||||
route: RouteLocationNormalizedLoaded;
|
||||
activeBrand: TypeBrand|null;
|
||||
activeRange: TypeRange|null;
|
||||
chickenRun: boolean;
|
||||
konami: KonamiCode|null;
|
||||
backgroundStyle: Record<string, string>;
|
||||
} {
|
||||
return {
|
||||
timeout: undefined,
|
||||
loading: true,
|
||||
route: useRoute(),
|
||||
activeBrand: null,
|
||||
activeRange: null,
|
||||
chickenRun: false,
|
||||
konami: null,
|
||||
backgroundStyle: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showFooter(): boolean {
|
||||
return this.route.name?.toString().startsWith("index") !== true;
|
||||
},
|
||||
showNavigation(): boolean {
|
||||
return ["ranges-slug"].find(routeName => this.route.name?.toString().startsWith(routeName)) !== undefined &&
|
||||
this.brandWithMultipleRanges;
|
||||
},
|
||||
brandWithMultipleRanges(): boolean {
|
||||
if(!this.activeBrand) {
|
||||
return false;
|
||||
}
|
||||
return this.activeBrand.ranges.length > 1;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.setTimeout();
|
||||
this.activeBrand = useAppStore().activeBrand;
|
||||
this.activeRange = useAppStore().activeRange;
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
// * INIT STATES
|
||||
window.ipcRendererObject.getJsonData().then((dataJson) => {
|
||||
useAppStore().setupAppData(dataJson, this.$i18n.locale, String(this.$i18n.fallbackLocale));
|
||||
this.backgroundStyle = this.buildBackgroundStyle(dataJson.settings);
|
||||
this.loading = false;
|
||||
this.setTimeout();
|
||||
dataJson.fonts.forEach(font => this.injectFontFace(font.font_name, font.font));
|
||||
this.redirectIfOneBrand(dataJson.items);
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
this.konami = new KonamiCode(() => {
|
||||
this.$nextTick(() => {
|
||||
this.chickenRun = true;
|
||||
setTimeout(() => {this.chickenRun = false;}, 12000);
|
||||
});
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.timeout);
|
||||
},
|
||||
methods: {
|
||||
t(text: string, params: Record<string,string> = {}, localeToUse: string|null = null): string {
|
||||
return ToolboxHelper.trans(text, params, localeToUse);
|
||||
},
|
||||
transField(model: Typei18nModel, field: Typei18nFieldList) {
|
||||
return ToolboxHelper.getTranslatableField(model, field);
|
||||
},
|
||||
injectFontFace(fontName: string, fontPath: string|null) {
|
||||
const newStyle = document.createElement("style");
|
||||
newStyle.nonce = document.querySelector("meta[name=\"csp-nonce\"]")?.getAttribute("content") ?? "";
|
||||
if(fontPath) {
|
||||
newStyle.appendChild(document.createTextNode(`@font-face { font-family: '${fontName}'; src: url('${dataUrl(fontPath)}');}`));
|
||||
newStyle.appendChild(document.createTextNode(`.f-${fontName} { font-family: '${fontName}' !important; }`));
|
||||
} else {
|
||||
newStyle.appendChild(document.createTextNode(`.f-${fontName} { font-family: ${fontName} !important; }`));
|
||||
}
|
||||
document.head.appendChild(newStyle);
|
||||
},
|
||||
/** Redirect to ranges.index if there is only one brand. */
|
||||
redirectIfOneBrand(brands: Array<TypeBrand>): void {
|
||||
if (brands.length === 1) {
|
||||
useAppStore().setActiveBrand(brands[0]);
|
||||
this.activeBrand = brands[0];
|
||||
navigateTo(useLocaleRoute()({"name": "brands-slug", "params": {"slug": this.transField(brands[0], "slug")}}));
|
||||
} else {
|
||||
useAppStore().setActiveBrand(null);
|
||||
this.activeBrand = null;
|
||||
}
|
||||
},
|
||||
/** Prolong le timer pour revenir au home screen. */
|
||||
setTimeout(): void {
|
||||
if(this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.timeout = setTimeout(() => {
|
||||
useAppStore().setActiveRange(null);
|
||||
window.ipcRendererObject.getJsonData().then((dataJson) => {
|
||||
this.redirectIfOneBrand(dataJson.items);
|
||||
});
|
||||
navigateTo(useLocaleRoute()({"name": "index"}));
|
||||
}, useAppStore().getSettings().home_timeout * 1000);
|
||||
});
|
||||
},
|
||||
/** Apply background color || image */
|
||||
buildBackgroundStyle(settings: TypeSettings): Record<string, string> {
|
||||
const style: Record<string, string> = {};
|
||||
if (settings.background_color) {
|
||||
style.backgroundColor = settings.background_color;
|
||||
}
|
||||
if (settings.background_image) {
|
||||
style.backgroundImage = `url("${dataUrl(settings.background_image)}")`;
|
||||
style.backgroundSize = "cover";
|
||||
style.backgroundPosition = "center";
|
||||
style.backgroundRepeat = "no-repeat";
|
||||
style.backgroundAttachment = "fixed";
|
||||
}
|
||||
return style;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "@expo/assets/scss/style.scss";
|
||||
@import "@fortawesome/fontawesome-svg-core/styles.css";
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
filter: blur(1rem);
|
||||
}
|
||||
.app-component.has-custom-background {
|
||||
min-height: 100vh;
|
||||
|
||||
.text-bg-light {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
// remove border
|
||||
#product-list-component {
|
||||
.btn.bg-body {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.img-thumbnail {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
35
resources/expo/NuxtLinkLocale.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { computed, defineComponent, h } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { DEFAULT_LOCALE } from "./i18n";
|
||||
|
||||
export default defineComponent({
|
||||
name: "NuxtLinkLocale",
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
to: { type: [String, Object], required: true },
|
||||
locale: { type: String, default: undefined },
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
const { locale: currentLocale } = useI18n({ useScope: "global" });
|
||||
|
||||
const target = computed(() => {
|
||||
const locale = props.locale ?? String(currentLocale.value);
|
||||
|
||||
if (typeof props.to === "string") {
|
||||
if (props.to.startsWith("/")) {
|
||||
if (locale === DEFAULT_LOCALE) {
|
||||
return props.to;
|
||||
}
|
||||
return `/${locale}${props.to === "/" ? "" : props.to}`;
|
||||
}
|
||||
return { name: `${props.to}___${locale}` };
|
||||
}
|
||||
|
||||
const route = props.to as { name?: string };
|
||||
return route.name ? { ...route, name: `${route.name}___${locale}` } : route;
|
||||
});
|
||||
|
||||
return () => h(RouterLink, { ...attrs, to: target.value }, slots);
|
||||
},
|
||||
});
|
||||
BIN
resources/expo/assets/pictures/Logo-LDC.jpg
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
resources/expo/assets/pictures/chicken_run/chicken1.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
resources/expo/assets/pictures/chicken_run/chicken2.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
resources/expo/assets/pictures/chicken_run/chicken3.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
resources/expo/assets/pictures/layout/home-bottom-banner.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
resources/expo/assets/pictures/layout/ldc-intl.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
resources/expo/assets/pictures/layout/top-hr.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
24
resources/expo/assets/scss/_bootstrap-variables.scss
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Override bootstrap colors
|
||||
$primary: $color-primary;
|
||||
$secondary: $color-secondary;
|
||||
$success: $color-success;
|
||||
$info: $color-info;
|
||||
$warning: $color-warning;
|
||||
$danger: $color-danger;
|
||||
$light: $color-light;
|
||||
$dark: $color-dark;
|
||||
$theme-colors: (
|
||||
primary: $primary,
|
||||
secondary: $secondary,
|
||||
success: $success,
|
||||
info: $info,
|
||||
warning: $warning,
|
||||
danger: $danger,
|
||||
light: $light,
|
||||
dark: $dark,
|
||||
);
|
||||
|
||||
// Override bootstrap vars
|
||||
$font-family-base: $font-default-family-base;
|
||||
$font-size-base: $font-default-size-base;
|
||||
$line-height-base: $line-default-height-base;
|
||||
63
resources/expo/assets/scss/_sweetalert-custom.scss
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
@keyframes swal2-show-custom {
|
||||
0% {
|
||||
transform: scale(0.7) translateX(-50%) translateY(-65%)
|
||||
}
|
||||
|
||||
45% {
|
||||
transform:scale(1.05) translateX(-50%) translateY(-65%)
|
||||
}
|
||||
|
||||
80% {
|
||||
transform:scale(0.95) translateX(-50%) translateY(-65%)
|
||||
}
|
||||
|
||||
100% {
|
||||
transform:scale(1) translateX(-50%) translateY(-65%)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swal2-hide-custom {
|
||||
0% {
|
||||
opacity:1;
|
||||
transform:scale(1) translateX(-50%) translateY(-65%)
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity:0;
|
||||
transform:scale(0.5) translateX(-50%) translateY(-65%)
|
||||
}
|
||||
}
|
||||
|
||||
div:where(.swal2-container) {
|
||||
display: block !important;
|
||||
|
||||
> .swal2-popup {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: 35%;
|
||||
left: 50%;
|
||||
display: block !important;
|
||||
width: inherit;
|
||||
align-self: inherit;
|
||||
grid: inherit;
|
||||
justify-self: inherit;
|
||||
transform: translateY(-22vh);
|
||||
transform: translateX(-50%) translateY(-65%);
|
||||
}
|
||||
|
||||
h2:where(.swal2-title) {
|
||||
font-size: xxx-large !important;
|
||||
}
|
||||
|
||||
.swal2-html-container {
|
||||
font-size: xx-large !important;
|
||||
}
|
||||
|
||||
.swal2-show {
|
||||
animation: swal2-show-custom .3s !important;
|
||||
}
|
||||
|
||||
.swal2-hide {
|
||||
animation: swal2-hide-custom .3s !important;
|
||||
}
|
||||
}
|
||||
30
resources/expo/assets/scss/_variables.scss
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// * COLORS
|
||||
$color-white: #fff;
|
||||
$color-light: #dadada;
|
||||
$color-dark: #212529;
|
||||
$color-success: #198754;
|
||||
$color-danger: #dc3545;
|
||||
$color-info: #0dcaf0;
|
||||
$color-warning: #ffc107;
|
||||
$color-primary: #0d6efd;
|
||||
$color-secondary: #6c757d;
|
||||
|
||||
// * FONTS
|
||||
$font-default-family-base: system-ui, -apple-system, "Segoe UI", "Roboto",
|
||||
"Helvetica Neue", "Arial", "Noto Sans", "Liberation Sans", sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; // - The default site font family.
|
||||
// Display tres haute résolution, augmentation de font size 1rem => 1.8rem
|
||||
$font-default-size-base: 1.4rem; // Assumes the browser default, typically `16px`
|
||||
$line-default-height-base: 1.5;
|
||||
|
||||
// * TOAST
|
||||
$toast-max-width: 950px;
|
||||
|
||||
// * Containers
|
||||
$container-max-widths: (
|
||||
sm: 540px,
|
||||
md: 720px,
|
||||
lg: 960px,
|
||||
xl: 1140px,
|
||||
xxl: 1520px
|
||||
);
|
||||
44
resources/expo/assets/scss/bootstrap.scss
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// 1. Include new custom variables
|
||||
@import "./bootstrap-variables";
|
||||
|
||||
// 2. Include functions first (so you can manipulate colors, SVGs, calc, etc)
|
||||
@import "bootstrap/scss/functions";
|
||||
|
||||
// 3. Include remainder of required Bootstrap stylesheets
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
|
||||
// 4. Include remainder of required parts
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/root";
|
||||
|
||||
// 5. Optionally include any other parts as needed
|
||||
@import "bootstrap/scss/alert";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/grid";
|
||||
@import "bootstrap/scss/helpers";
|
||||
@import "bootstrap/scss/images";
|
||||
@import "bootstrap/scss/list-group";
|
||||
@import "bootstrap/scss/nav";
|
||||
@import "bootstrap/scss/navbar";
|
||||
@import "bootstrap/scss/pagination";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/tooltip";
|
||||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/type";
|
||||
@import "bootstrap/scss/utilities";
|
||||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/accordion";
|
||||
@import "bootstrap/scss/toasts";
|
||||
@import "bootstrap/scss/badge";
|
||||
|
||||
// 6. Optionally include utilities API last to generate classes based on the Sass map in `_utilities.scss`
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
|
||||
75
resources/expo/assets/scss/layout.scss
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
body {
|
||||
@include font-size(1rem);
|
||||
|
||||
max-height: 100vh;
|
||||
// * Use BS var to set BG as on home page
|
||||
// background-color: unset;
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
@include font-size(2rem);
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include font-size(3rem);
|
||||
}
|
||||
|
||||
.app-component {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
|
||||
> .container-fluid {
|
||||
flex-grow: 1;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
> footer {
|
||||
box-shadow: 0 -0.125rem 0.25rem rgb(0 0 0 / 7.5%) !important;
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
@include font-size(2rem);
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include font-size(2.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.home-title {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
@include font-size(3rem);
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include font-size(4rem);
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
display: inline-block;
|
||||
width: 100vw;
|
||||
background-color: $color-white;
|
||||
content: ' ';
|
||||
transform: translateY(-50%);
|
||||
|
||||
@include rfs(1rem, height);
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: calc(100% + 4vw);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: calc(100% + 4vw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
38
resources/expo/assets/scss/style.scss
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
@import "./variables";
|
||||
@import "./bootstrap";
|
||||
@import "./sweetalert-custom";
|
||||
|
||||
// * App
|
||||
@import "./layout";
|
||||
|
||||
.shelf {
|
||||
position: relative;
|
||||
right: 0;
|
||||
left: 1rem;
|
||||
border-right: 2rem solid transparent;
|
||||
border-bottom: 2.5rem solid #a5a5a5;
|
||||
border-left: 2rem solid transparent;
|
||||
// margin: 0 0 4rem;
|
||||
// margin-bottom: 4rem;
|
||||
cursor: pointer;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: 0;
|
||||
bottom: -3.1rem;
|
||||
left: -2rem;
|
||||
width: calc(100% + 4rem); /* IE9+ */
|
||||
height: 0.6rem;
|
||||
background: #686868;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap overload
|
||||
|
||||
@media (width >= 1400px) {
|
||||
.modal-xxl {
|
||||
--bs-modal-width: 1640px;
|
||||
}
|
||||
}
|
||||
82
resources/expo/auto-imports.d.ts
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const dataBaseUrl: typeof import('./composables/dataUrl')['dataBaseUrl']
|
||||
const dataUrl: typeof import('./composables/dataUrl')['dataUrl']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const navigateTo: typeof import('@expo/nuxt-compat')['navigateTo']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAppStore: typeof import('./stores/useAppStore')['useAppStore']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLocaleRoute: typeof import('@expo/nuxt-compat')['useLocaleRoute']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useNuxtApp: typeof import('@expo/nuxt-compat')['useNuxtApp']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useSwitchLocalePath: typeof import('@expo/nuxt-compat')['useSwitchLocalePath']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
81
resources/expo/classes/AppState.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import Toast from "./Toast";
|
||||
|
||||
export default class AppState {
|
||||
/** Json data as exported from zip and compliant with schemas */
|
||||
private jsonData: TypeApp;
|
||||
|
||||
/** Current App locale */
|
||||
private locale: string;
|
||||
/** Fallback App locale */
|
||||
private fallbackLocale: string;
|
||||
|
||||
constructor(jsonData: TypeApp, locale: string, fallbackLocale: string) {
|
||||
this.jsonData = jsonData;
|
||||
this.locale = locale;
|
||||
this.fallbackLocale = fallbackLocale;
|
||||
}
|
||||
|
||||
public settings(): TypeSettings {
|
||||
return this.jsonData.settings;
|
||||
}
|
||||
|
||||
public switchLocale(locale: string): void {
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
public geti18N() {
|
||||
return {
|
||||
locale: this.locale,
|
||||
fallbackLocale: this.fallbackLocale,
|
||||
};
|
||||
}
|
||||
|
||||
public getFallbackLocale(): string {
|
||||
return this.fallbackLocale;
|
||||
}
|
||||
|
||||
// * --- BRANDS
|
||||
|
||||
public brands(): Array<TypeBrand> {
|
||||
return this.jsonData.items;
|
||||
}
|
||||
|
||||
public brand(slug:string): TypeBrand|undefined {
|
||||
return this.jsonData.items.find(brand => brand.slug[this.locale] === slug);
|
||||
}
|
||||
|
||||
public brandFromRange(range:TypeRange): TypeBrand {
|
||||
const brand = this.jsonData.items.find(brand => brand.id === range.brand_id);
|
||||
if (!brand) {
|
||||
Toast.toast("Une erreur est survenue");
|
||||
throw new Error(`Could not find brand with id ${range.brand_id}`);
|
||||
}
|
||||
return brand;
|
||||
}
|
||||
|
||||
// * --- END BRANDS
|
||||
|
||||
// * --- RANGES
|
||||
|
||||
public ranges(brand: TypeBrand | null = null): Array<TypeRange> {
|
||||
const ranges: Array<TypeRange> = [];
|
||||
this.jsonData.items.map((brandItem) => {
|
||||
if (brand === null || brand?.id === brandItem.id) {
|
||||
ranges.push(...brandItem.ranges);
|
||||
return brandItem;
|
||||
}
|
||||
return brandItem;
|
||||
});
|
||||
return ranges.sort((rangeA, rangeB) => rangeA.order - rangeB.order);
|
||||
}
|
||||
|
||||
public range(slug:string): TypeRange|undefined {
|
||||
return this.ranges().find(range => range.slug[this.locale] === slug);
|
||||
}
|
||||
|
||||
// * --- END RANGES
|
||||
|
||||
public font(fontId: number): TypeFont|undefined {
|
||||
return this.jsonData.fonts.find(font=> font.id === fontId);
|
||||
}
|
||||
}
|
||||
43
resources/expo/classes/Toast.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import log from "electron-log";
|
||||
import type { $Bus } from "@expo/plugins/emitter";
|
||||
|
||||
export enum ToastTypeEnum {
|
||||
default = "default",
|
||||
success = "success",
|
||||
info = "info",
|
||||
error = "error",
|
||||
warning = "warning",
|
||||
}
|
||||
|
||||
export const ToastType = Object.freeze({
|
||||
default: ToastTypeEnum.default,
|
||||
success: ToastTypeEnum.success,
|
||||
info: ToastTypeEnum.info,
|
||||
error: ToastTypeEnum.error,
|
||||
warning: ToastTypeEnum.warning,
|
||||
});
|
||||
|
||||
export default abstract class Toast {
|
||||
|
||||
/** Affiche un toast message */
|
||||
public static toast(message: string, type: ToastTypeEnum = ToastType.default, title: string | null = null): void {
|
||||
const $bus = useNuxtApp().$bus as $Bus;
|
||||
$bus.$emit("toast", {message, type, title});
|
||||
const prefix: string = `${type.toLowerCase()} : `;
|
||||
switch (type) {
|
||||
case ToastType.info:
|
||||
log.info(prefix, message);
|
||||
break;
|
||||
case ToastType.warning:
|
||||
log.warn(prefix, message);
|
||||
break;
|
||||
case ToastType.error:
|
||||
log.error(prefix, message);
|
||||
break;
|
||||
case ToastType.default:
|
||||
case ToastType.success:
|
||||
default:
|
||||
log.verbose(prefix, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
resources/expo/classes/ToolboxHelper.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
export default class ToolboxHelper {
|
||||
/**
|
||||
* Get the contrast for any color
|
||||
*
|
||||
* @param hexcolor An HTML Hex color
|
||||
* @returns
|
||||
*/
|
||||
public static getContrastYIQ(hexcolor: string): "#000000"|"#FFFFFF" {
|
||||
const r = parseInt(hexcolor.substring(1,3),16);
|
||||
const g = parseInt(hexcolor.substring(3,5),16);
|
||||
const b = parseInt(hexcolor.substring(5,7),16);
|
||||
const yiq = ((r*299)+(g*587)+(b*114))/1000;
|
||||
return (yiq >= 128) ? "#000000" : "#FFFFFF";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HTML Hex color to RGBA
|
||||
*
|
||||
* @param hexcolor An HTML Hex color
|
||||
* @param alpha The transparency to apply
|
||||
* @returns
|
||||
*/
|
||||
public static toRGBA(hexcolor: string, alpha: number = 1): string {
|
||||
const r = parseInt(hexcolor.substring(1,3),16);
|
||||
const g = parseInt(hexcolor.substring(3,5),16);
|
||||
const b = parseInt(hexcolor.substring(5,7),16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
public static randomInteger(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
public static trans(text: string, params: Record<string,string> = {}, localeToUse: string|null = null): string {
|
||||
const { t } = useI18n({ useScope: "global"});
|
||||
if(localeToUse) {
|
||||
return t(text, params, {
|
||||
locale: localeToUse,
|
||||
});
|
||||
}
|
||||
return t(text, params);
|
||||
}
|
||||
|
||||
public static getTranslatableField(model: Typei18nModel, field: Typei18nFieldList): string {
|
||||
const { locale, fallbackLocale } = useAppStore().getI18n();
|
||||
const i18nField: Typei18nField | null = (model[field as keyof typeof model] !== undefined ?
|
||||
model[field as keyof typeof model] : null) as Typei18nField | null;
|
||||
|
||||
if (!i18nField) {
|
||||
return "undefined";
|
||||
}
|
||||
|
||||
return i18nField[locale] !== undefined ? i18nField[locale] : i18nField[fallbackLocale];
|
||||
}
|
||||
}
|
||||
149
resources/expo/components/ChickenRun.vue
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
:class="{
|
||||
'chicken-run position-absolute top-0 left-0 h-100 w-100': true,
|
||||
'd-none': !runAnimation.runAnimation
|
||||
}"
|
||||
>
|
||||
<div ref="chickenA">
|
||||
<img
|
||||
v-for="(i) of data.chickenMultiplier"
|
||||
:key="i"
|
||||
src="assets/pictures/chicken_run/chicken1.png"
|
||||
alt="Poulet 1"
|
||||
class="img-fluid chicken"
|
||||
>
|
||||
</div>
|
||||
<div ref="chickenB">
|
||||
<img
|
||||
v-for="(i) of data.chickenMultiplier"
|
||||
:key="i"
|
||||
src="assets/pictures/chicken_run/chicken2.png"
|
||||
alt="Poulet 2"
|
||||
class="img-fluid chicken"
|
||||
>
|
||||
</div>
|
||||
<div ref="chickenC">
|
||||
<img
|
||||
v-for="(i) of data.chickenMultiplier"
|
||||
:key="i"
|
||||
src="assets/pictures/chicken_run/chicken3.png"
|
||||
alt="Poulet 3"
|
||||
class="img-fluid chicken"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
function makeNewPosition() {
|
||||
const containerRect = container.value!.getBoundingClientRect();
|
||||
// Get viewport dimensions (remove the dimension of the div)
|
||||
const h = containerRect.height - 10;
|
||||
const w = containerRect.width - 10;
|
||||
|
||||
const nh = Math.floor(Math.random() * h);
|
||||
const nw = Math.floor(Math.random() * w);
|
||||
|
||||
return [nh, nw];
|
||||
|
||||
}
|
||||
|
||||
function animateDiv(target: HTMLElement) {
|
||||
const newq = makeNewPosition();
|
||||
const oldq = target.getBoundingClientRect();
|
||||
const speed = calcSpeed([oldq.top, oldq.left], newq);
|
||||
|
||||
const promise = Promise.all([target.animate({
|
||||
top: `${newq[0]}px`,
|
||||
left: `${newq[1]}px`
|
||||
}, speed).finished]);
|
||||
if(runAnimation.runAnimation) {
|
||||
promise.then(() => animateDiv(target));
|
||||
} else {
|
||||
data.runningAnimation = false;
|
||||
}
|
||||
}
|
||||
|
||||
function calcSpeed(prev: number[], next:number[]) {
|
||||
const x = Math.abs(prev[1] - next[1]);
|
||||
const y = Math.abs(prev[0] - next[0]);
|
||||
|
||||
const greatest = x > y ? x : y;
|
||||
const speed = Math.ceil(greatest / data.speedModifier);
|
||||
|
||||
return speed;
|
||||
}
|
||||
|
||||
function setRandomStartCoord(container: DOMRect, el: HTMLElement) {
|
||||
el.style.setProperty("top", `${Math.floor(Math.random() * container.height)}px`);
|
||||
el.style.setProperty("left", `${Math.floor(Math.random() * container.width)}px`);
|
||||
}
|
||||
|
||||
function animateElements(elements: NodeListOf<ChildNode>) {
|
||||
const containerRect = container.value!.getBoundingClientRect();
|
||||
(Array.from(elements))
|
||||
.filter((el) => el.nodeType !== Node.TEXT_NODE)
|
||||
.forEach((el) => {
|
||||
setRandomStartCoord(containerRect, el as HTMLElement);
|
||||
animateDiv(el as HTMLElement);
|
||||
});
|
||||
}
|
||||
|
||||
const data = reactive<{
|
||||
runningAnimation: boolean;
|
||||
speedModifier: number;
|
||||
chickenMultiplier: number;
|
||||
}>({
|
||||
runningAnimation: false,
|
||||
speedModifier: 0.6,
|
||||
chickenMultiplier: 1,
|
||||
});
|
||||
|
||||
const runAnimation = defineProps({
|
||||
runAnimation: {
|
||||
type:Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const container = ref<HTMLDivElement>();
|
||||
const chickenA = ref<HTMLDivElement>();
|
||||
const chickenB = ref<HTMLDivElement>();
|
||||
const chickenC = ref<HTMLDivElement>();
|
||||
|
||||
const animate = () => {
|
||||
if(!data.runningAnimation) {
|
||||
data.runningAnimation = true;
|
||||
data.chickenMultiplier = Math.floor(Math.random() * 4) + 1;
|
||||
nextTick(() => {
|
||||
animateElements(chickenA.value!.childNodes);
|
||||
animateElements(chickenB.value!.childNodes);
|
||||
animateElements(chickenC.value!.childNodes);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if(runAnimation.runAnimation) {
|
||||
animate();
|
||||
}
|
||||
|
||||
watch(runAnimation, () => {
|
||||
if (runAnimation.runAnimation) {
|
||||
animate();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.chicken-run {
|
||||
.chicken {
|
||||
width: 5vw;
|
||||
height: auto;
|
||||
position:fixed;
|
||||
top: 5rem;
|
||||
left: 5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
129
resources/expo/components/NavigationMenu.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<div class="container border-top border-3 border-secondary">
|
||||
<div class="row py-4">
|
||||
<div class="col arrow pe-3 d-flex align-items-center justify-content-end">
|
||||
<button
|
||||
class="rounded-pill btn btn-secondary px-4 py-1"
|
||||
:disabled="previousRange === null"
|
||||
:title="previousRange ? t('view_range_products', {'range_name': transField(previousRange, 'name')}) : ''"
|
||||
data-bs-toggle="tooltip"
|
||||
@click="gotoRange(previousRange)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="['fas', 'arrow-left']" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="col home text-center px-3">
|
||||
<button
|
||||
class="rounded-pill btn btn-secondary px-4 py-1"
|
||||
:title="activeBrand ? t('view_brand_ranges', {'brand_name': transField(activeBrand, 'name')}) : ''"
|
||||
data-bs-toggle="tooltip"
|
||||
@click="gotoBrand(activeBrand)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="['fas', 'house']" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="col arrow ps-3 d-flex align-items-center justify-content-start">
|
||||
<button
|
||||
class="rounded-pill btn btn-secondary px-4 py-1"
|
||||
:disabled="nextRange === null"
|
||||
:title="nextRange ? t('view_range_products', {'range_name': transField(nextRange, 'name')}) : ''"
|
||||
data-bs-toggle="tooltip"
|
||||
@click="gotoRange(nextRange)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="['fas', 'arrow-right']" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { defineComponent } from "vue";
|
||||
import ToolboxHelper from "@expo/classes/ToolboxHelper";
|
||||
|
||||
export default defineComponent({
|
||||
name: "NavigationMenu",
|
||||
components: { FontAwesomeIcon },
|
||||
data(): {
|
||||
locale: string;
|
||||
brands: Array<TypeRange>;
|
||||
nextRange: TypeRange | null;
|
||||
previousRange: TypeRange | null;
|
||||
activeRange: TypeRange | null;
|
||||
activeBrand: TypeBrand | null;
|
||||
} {
|
||||
return {
|
||||
locale: "",
|
||||
brands: [],
|
||||
nextRange: null,
|
||||
previousRange: null,
|
||||
activeRange: null,
|
||||
activeBrand: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.refreshData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.refreshData();
|
||||
const { locale } = useAppStore().getI18n();
|
||||
this.locale = locale;
|
||||
},
|
||||
methods: {
|
||||
t(text: string, params: Record<string,string> = {}, localeToUse: string|null = null): string {
|
||||
return ToolboxHelper.trans(text, params, localeToUse);
|
||||
},
|
||||
transField(model: Typei18nModel, field: Typei18nFieldList) {
|
||||
return ToolboxHelper.getTranslatableField(model, field);
|
||||
},
|
||||
refreshData() {
|
||||
this.activeBrand = useAppStore().activeBrand;
|
||||
this.activeRange = useAppStore().activeRange;
|
||||
this.nextRange = useAppStore().getNextRange();
|
||||
this.previousRange = useAppStore().getPreviousRange();
|
||||
},
|
||||
gotoBrand(activeBrand: TypeBrand|null) {
|
||||
if(!activeBrand) {
|
||||
return;
|
||||
}
|
||||
useAppStore().setActiveRange(null);
|
||||
navigateTo(useLocaleRoute()({"name": "brands-slug", "params": {"slug": activeBrand.slug[this.locale]}}));
|
||||
},
|
||||
gotoRange(range: TypeRange|null) {
|
||||
if(!range) {
|
||||
return;
|
||||
}
|
||||
useAppStore().setActiveRange(range);
|
||||
navigateTo(useLocaleRoute()({"name": "ranges-slug", "params": {"slug": range.slug[this.locale]}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/vendor/rfs";
|
||||
.arrow > button {
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(1.5rem, font-size);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(4rem, font-size);
|
||||
}
|
||||
}
|
||||
.home > button {
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(2rem, font-size);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(4rem, font-size);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
199
resources/expo/components/ProductDescriptionModal.vue
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<template>
|
||||
<div
|
||||
id="product-modal"
|
||||
ref="modal"
|
||||
class="modal fade"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-xl modal-fullscreen-md-down">
|
||||
<div class="modal-content bg-white">
|
||||
<div class="modal-header position-relative">
|
||||
<h5
|
||||
v-if="product"
|
||||
:class="{ ...{
|
||||
'product-title': true,
|
||||
'fw-bold text-capitalize text-center rounded mt-4 py-3 px-4 flex-grow-1 mx-5 px-5': true,
|
||||
|
||||
'fs-1 mt-2 mt-md-5': true,
|
||||
}, ... rangeFont(range.font_id)}"
|
||||
>
|
||||
{{ transField(product, 'name') }}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close me-4 position-absolute end-0 top-1"
|
||||
:aria-label="t('close_products_modal')"
|
||||
:title="t('close_products_modal')"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-dismiss="modal"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<img
|
||||
v-if="product"
|
||||
class="img-fluid"
|
||||
:src="dataUrl(product.picture)"
|
||||
:alt="t('products_logo', {'product_name': transField(product, 'name')})"
|
||||
:title="t('products_logo', {'product_name': transField(product, 'name')})"
|
||||
data-bs-toggle="tooltip"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col pt-5 ps-md-4 description">
|
||||
<div
|
||||
v-if="product"
|
||||
v-sane-html="transField(product, 'description')"
|
||||
class="p-md-3 my-md-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import { Modal } from "bootstrap";
|
||||
import DOMPurify from "dompurify";
|
||||
import type { DirectiveBinding, PropType } from "vue";
|
||||
import { defineComponent } from "vue";
|
||||
import ToolboxHelper from "@expo/classes/ToolboxHelper";
|
||||
import { Tooltips } from "@expo/vueModules/bsTooltips";
|
||||
|
||||
const saneHtml = {
|
||||
mounted: (el: HTMLElement, binding: DirectiveBinding) => {
|
||||
el.innerHTML = DOMPurify.sanitize(binding.value);
|
||||
},
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "ProductDescriptionModal",
|
||||
directives: {
|
||||
saneHtml,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
range: {
|
||||
type: Object as PropType<TypeRange>,
|
||||
required: true,
|
||||
},
|
||||
product: {
|
||||
type: Object as PropType<TypeProduct>|null,
|
||||
nullable: true,
|
||||
default: undefined,
|
||||
required: false,
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
"closed", "openned"
|
||||
],
|
||||
data(): {
|
||||
modal: Modal|null;
|
||||
tooltips: Tooltips|null;
|
||||
} {
|
||||
return {
|
||||
modal: null,
|
||||
tooltips: null
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
show() {
|
||||
this.$nextTick(() => {
|
||||
if(this.show) {
|
||||
this.modal?.show();
|
||||
} else {
|
||||
this.modal?.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
const modal = this.$refs.modal as HTMLElement;
|
||||
modal.addEventListener("shown.bs.modal", () => {
|
||||
this.tooltips = Tooltips.make({type:"parent", parentSelector: "#product-modal"});
|
||||
this.$emit("openned");
|
||||
});
|
||||
modal.addEventListener("hidden.bs.modal", () => {
|
||||
this.tooltips?.closeBootstrapTooltip();
|
||||
this.$emit("closed");
|
||||
});
|
||||
this.modal = Modal.getOrCreateInstance(modal);
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.tooltips?.closeBootstrapTooltip();
|
||||
this.modal?.hide();
|
||||
},
|
||||
methods: {
|
||||
t(text: string, params: Record<string,string> = {}, localeToUse: string|null = null): string {
|
||||
return ToolboxHelper.trans(text, params, localeToUse);
|
||||
},
|
||||
transField(model: Typei18nModel, field: Typei18nFieldList) {
|
||||
return ToolboxHelper.getTranslatableField(model, field);
|
||||
},
|
||||
rangeFont(fontId: number) {
|
||||
const font = useAppStore().getFont(fontId);
|
||||
const out: Map<string, boolean> = new Map<string, boolean>();
|
||||
if(font) {
|
||||
out.set(`f-${font.font_name}`, true);
|
||||
}
|
||||
return Object.fromEntries(out.entries());
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/vendor/rfs";
|
||||
|
||||
.btn-close {
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(1.5em, padding-bottom);
|
||||
@include rfs(1.3em, font-size);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(2em, padding-bottom);
|
||||
@include rfs(1.5em, font-size);
|
||||
}
|
||||
}
|
||||
|
||||
.product-title {
|
||||
@include rfs(1.4rem, font-size);
|
||||
@include rfs(1rem, padding-bottom);
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(1.8rem, font-size);
|
||||
@include rfs(3rem, padding-bottom);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(2.8rem, font-size);
|
||||
@include rfs(6rem, padding-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
@include rfs(1em, font-size);
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(1.1em, font-size);
|
||||
@include rfs(1.1em, padding-bottom);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(1.15em, font-size);
|
||||
@include rfs(1.35em, padding-bottom);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
220
resources/expo/components/ProductList.vue
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<template>
|
||||
<div
|
||||
id="product-list-component"
|
||||
class="container justify-content-center d-flex flex-column"
|
||||
>
|
||||
<div
|
||||
v-if="range.products"
|
||||
ref="rangeList"
|
||||
class="row justify-content-around"
|
||||
>
|
||||
<div
|
||||
v-for="(productPair, productIndex) of chunkInefficient(range.products, 2)"
|
||||
:key="productIndex"
|
||||
:class="{'row justify-content-center': true}"
|
||||
>
|
||||
<div
|
||||
v-for="(product, productPairIndex) of productPair"
|
||||
:key="productPairIndex"
|
||||
class="'col-12 col-md-6 product text-center"
|
||||
>
|
||||
<button
|
||||
class="d-inline-block btn bg-body position-relative"
|
||||
:title="t('more_about_this_product')"
|
||||
data-bs-toggle="tooltip"
|
||||
@click.stop="showProduct(product, $event)"
|
||||
>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<img
|
||||
class="img-thumbnail"
|
||||
:src="dataUrl(product.picture)"
|
||||
:alt="t('products_logo', {'product_name': transField(product, 'name')})"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<span
|
||||
:class="{
|
||||
...{
|
||||
'product-title': true,
|
||||
'd-flex fw-bold text-capitalize justify-content-center align-items-center rounded mt-4 py-3 px-4': true
|
||||
},
|
||||
...rangeFont(range.font_id)
|
||||
}"
|
||||
:style="{
|
||||
'color': getContrastYIQ(range.color),
|
||||
'background-color': range.color
|
||||
}"
|
||||
>
|
||||
{{ transField(product, 'name') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row"
|
||||
>
|
||||
<div class="col">
|
||||
<span>{{ t('no_products_available') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import type { DirectiveBinding } from "vue";
|
||||
import { defineComponent } from "vue";
|
||||
import { useRoute, type RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import ToolboxHelper from "@expo/classes/ToolboxHelper";
|
||||
import { Tooltips } from "@expo/vueModules/bsTooltips";
|
||||
|
||||
const saneHtml = {
|
||||
mounted: (el: HTMLElement, binding: DirectiveBinding) => {
|
||||
el.innerHTML = DOMPurify.sanitize(binding.value);
|
||||
},
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "ProductList",
|
||||
directives: {
|
||||
saneHtml,
|
||||
},
|
||||
props: {
|
||||
range: {
|
||||
type: Object as PropType<TypeRange>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
selectedProduct(payload: {product: TypeProduct}) {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
data(): {
|
||||
route: RouteLocationNormalizedLoaded;
|
||||
tooltips: Tooltips|null;
|
||||
} {
|
||||
return {
|
||||
route: useRoute(),
|
||||
tooltips: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {this.tooltips = Tooltips.make({type:"parent", parentSelector: "#product-list-component"});});
|
||||
this.$nextTick(this.applySameHeight);
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.tooltips?.closeBootstrapTooltip();
|
||||
},
|
||||
methods: {
|
||||
chunkInefficient(products: Array<TypeProduct>, chunkSize: number):Array<Array<TypeProduct>> {
|
||||
const out:Array<Array<TypeProduct>> = [];
|
||||
let row: Array<TypeProduct> = [];
|
||||
|
||||
products.forEach((elem: TypeProduct, i: number) => {
|
||||
row.push(elem);
|
||||
if (i % chunkSize && row.length) {
|
||||
out.push(row);
|
||||
row = [];
|
||||
}
|
||||
});
|
||||
if (row.length) {
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
t(text: string, params: Record<string, string> = {}, localeToUse: string | null = null): string {
|
||||
return ToolboxHelper.trans(text, params, localeToUse);
|
||||
},
|
||||
transField(model: Typei18nModel, field: Typei18nFieldList) {
|
||||
return ToolboxHelper.getTranslatableField(model, field);
|
||||
},
|
||||
getContrastYIQ(hexcolor: string): string {
|
||||
return ToolboxHelper.getContrastYIQ(hexcolor);
|
||||
},
|
||||
toRGBA(hexcolor: string, alpha: number = 1): string {
|
||||
return ToolboxHelper.toRGBA(hexcolor, alpha);
|
||||
},
|
||||
rangeFont(fontId: number) {
|
||||
const font = useAppStore().getFont(fontId);
|
||||
const out: Map<string, boolean> = new Map<string, boolean>();
|
||||
if(font) {
|
||||
out.set(`f-${font.font_name}`, true);
|
||||
}
|
||||
return Object.fromEntries(out.entries());
|
||||
},
|
||||
showProduct(product: TypeProduct, $event: Event) {
|
||||
this.tooltips?.refreshTooltips();
|
||||
const el = $event.target as HTMLButtonElement;
|
||||
el.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center"
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.$emit("selectedProduct", {product});
|
||||
}, 500);
|
||||
},
|
||||
/** Pour que ces putains de titres label aient la meme hauteur. */
|
||||
applySameHeight() {
|
||||
const spanList = (this.$refs.rangeList as HTMLDivElement).querySelectorAll(".product-title") as NodeListOf<HTMLSpanElement>;
|
||||
let maxHeight = 0;
|
||||
spanList.forEach((productTitle) => {
|
||||
const spanHeight = productTitle.getBoundingClientRect().height;
|
||||
if (spanHeight > maxHeight) {
|
||||
maxHeight = spanHeight;
|
||||
}
|
||||
});
|
||||
spanList.forEach(productTitle => {
|
||||
productTitle.style.setProperty("min-height", `${maxHeight}px`);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/vendor/rfs";
|
||||
|
||||
.product {
|
||||
transition: all 0.2s ease-out allow-discrete;
|
||||
@include rfs(3.5rem, padding-top);
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(4.5rem, padding-top);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(6rem, padding-top);
|
||||
}
|
||||
|
||||
.product-title {
|
||||
@include rfs(1.4rem, font-size);
|
||||
@include rfs(1rem, padding-bottom);
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(1.8rem, font-size);
|
||||
@include rfs(3rem, padding-bottom);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(2.8rem, font-size);
|
||||
@include rfs(6rem, padding-bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
106
resources/expo/components/ToastContainer.vue
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
ref="toastMessage"
|
||||
:class="{
|
||||
'position-fixed bottom-0 end-0 me-4 mb-5 p-3': true,
|
||||
'toast fade hide': true,
|
||||
'text-bg-body': type === ToastType.default,
|
||||
'text-bg-success': type === ToastType.success,
|
||||
'text-bg-danger': type === ToastType.error,
|
||||
'text-bg-info': type === ToastType.info,
|
||||
'text-bg-warning': type === ToastType.warning,
|
||||
}"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div class="d-flex">
|
||||
<div
|
||||
v-if="title"
|
||||
class="toast-header"
|
||||
>
|
||||
<strong class="me-auto">{{ title }}</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="toast"
|
||||
aria-label="Fermer"
|
||||
/>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!title"
|
||||
type="button"
|
||||
:class="{
|
||||
'btn-close me-2 m-auto': true,
|
||||
'btn-close-white': [
|
||||
ToastType.success,
|
||||
ToastType.error
|
||||
].find((toastType) => toastType === type)
|
||||
}"
|
||||
data-bs-dismiss="toast"
|
||||
aria-label="Fermer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Toast } from "bootstrap";
|
||||
import { defineComponent } from "vue";
|
||||
import { ToastType } from "@expo/classes/Toast";
|
||||
import type { $Bus } from "@expo/plugins/emitter";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ToastContainer",
|
||||
data(): {
|
||||
ToastType: typeof ToastType;
|
||||
type: ToastTypeEnum;
|
||||
class: string;
|
||||
title: string|null;
|
||||
message: string;
|
||||
} {
|
||||
return {
|
||||
ToastType,
|
||||
type: ToastType.default,
|
||||
class: "text-bg-body",
|
||||
title: null,
|
||||
message: "",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const $bus = useNuxtApp().$bus as $Bus;
|
||||
$bus.$on("toast", (payload: {message:string, type:ToastTypeEnum, title: string|null}) => {
|
||||
const {message, type, title} = payload;
|
||||
const myToastEl = this.$refs.toastMessage as HTMLElement;
|
||||
this.title = title;
|
||||
this.type = type;
|
||||
this.message = message;
|
||||
Toast.getOrCreateInstance(myToastEl).show();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
|
||||
.toast {
|
||||
@include font-size(1rem);
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include font-size(2rem);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include font-size(3rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
187
resources/expo/components/layout/AppFooter.vue
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<footer
|
||||
v-if="brands.length > 1 || versions.isDebug"
|
||||
id="footer"
|
||||
ref="footer"
|
||||
class="container-fluid flex-grow-0"
|
||||
:style="`background-color: ${activeRange && brandWithMultipleRanges ? activeRange.color : activeBrand?.color} !important`"
|
||||
>
|
||||
<div
|
||||
v-if="brands.length > 1"
|
||||
class="row"
|
||||
>
|
||||
<div class="col-12 col-md-4 d-flex justify-content-center align-items-center">
|
||||
<span
|
||||
class="fw-bold d-block px-5"
|
||||
:style="{
|
||||
'color': activeRange && brandWithMultipleRanges ? getContrastYIQ(activeRange.color) : (activeBrand ? getContrastYIQ(activeBrand.color) : 'inherit')
|
||||
}"
|
||||
>
|
||||
{{ transField(settings, 'footer_phrase') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col col-md-8">
|
||||
<Swiper
|
||||
class="z-0"
|
||||
:scrollbar="{
|
||||
hide: true,
|
||||
}"
|
||||
:modules="modules"
|
||||
:space-between="30"
|
||||
:breakpoints="{
|
||||
'300': {
|
||||
slidesPerView: 2,
|
||||
spaceBetween: 20,
|
||||
},
|
||||
'560': {
|
||||
slidesPerView: 4,
|
||||
spaceBetween: 40,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<SwiperSlide
|
||||
v-for="(brand, brandIndex) of brands"
|
||||
:key="brandIndex"
|
||||
>
|
||||
<NuxtLinkLocale
|
||||
class="swiper-slide"
|
||||
:to="`/brands/${transField(brand, 'slug')}`"
|
||||
data-bs-toggle="tooltip"
|
||||
:title="t('view_brand_ranges', {'brand_name': transField(brand, 'name')})"
|
||||
@click.prevent="gotoBrand(brand)"
|
||||
>
|
||||
<img
|
||||
class="img-fluid main-logo"
|
||||
:src="dataUrl(brand.picture)"
|
||||
:alt="`Marque ${transField(brand, 'name')}`"
|
||||
:title="t('brands_logo', {'brand_name': transField(brand, 'name')})"
|
||||
data-bs-toggle="tooltip"
|
||||
>
|
||||
</NuxtLinkLocale>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="versions.isDebug"
|
||||
class="row"
|
||||
:style="{
|
||||
'color': activeRange ? getContrastYIQ(activeRange.color) : (activeBrand ? getContrastYIQ(activeBrand.color) : 'inherit')
|
||||
}"
|
||||
>
|
||||
<div class="col text-center">
|
||||
<span>{{ appInfo.licence }}</span> -
|
||||
<span>{{ appInfo.name }}</span> -
|
||||
<span>{{ appInfo.version }}</span> -
|
||||
<span class="fw-bold">UWIL</span>
|
||||
© {{ (new Date()).getFullYear() }}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Scrollbar } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/vue";
|
||||
import { defineComponent } from "vue";
|
||||
import { useRoute, type RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import ToolboxHelper from "@expo/classes/ToolboxHelper";
|
||||
import type { appInfo } from "@expo/electron/main";
|
||||
import { useAppStore } from "@expo/stores/useAppStore";
|
||||
import { Tooltips } from "@expo/vueModules/bsTooltips";
|
||||
|
||||
export default defineComponent({
|
||||
name: "AppFooter",
|
||||
components: {Swiper, SwiperSlide},
|
||||
props: {
|
||||
activeBrand: {
|
||||
type: Object as PropType<TypeBrand|null>,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
activeRange: {
|
||||
type: Object as PropType<TypeRange|null>,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
},
|
||||
data(): {
|
||||
locale: string;
|
||||
versions: typeof window.versions;
|
||||
appInfo: typeof appInfo;
|
||||
settings: TypeSettings;
|
||||
route: RouteLocationNormalizedLoaded;
|
||||
brands: Array<TypeBrand>;
|
||||
modules: Array<{
|
||||
name: string;
|
||||
}>;
|
||||
tooltips: Tooltips|null;
|
||||
} {
|
||||
return {
|
||||
locale: "",
|
||||
route: useRoute(),
|
||||
versions: window.versions,
|
||||
appInfo: {
|
||||
version: "",
|
||||
name: "",
|
||||
licence: ""
|
||||
},
|
||||
settings: {} as TypeSettings,
|
||||
brands: [],
|
||||
modules: [Scrollbar],
|
||||
tooltips: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
brandWithMultipleRanges(): boolean {
|
||||
if(!this.activeBrand) {
|
||||
return false;
|
||||
}
|
||||
return this.activeBrand.ranges.length > 1;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route(to: RouteLocationNormalizedLoaded) {
|
||||
if(to.name?.toString().startsWith("index")) {
|
||||
// * Reset active Brand on main page
|
||||
useAppStore().setActiveBrand(null);
|
||||
useAppStore().setActiveRange(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
this.settings = useAppStore().data.settings();
|
||||
window.ipcRendererObject.getAppInfo().then((appInfo) => {this.appInfo = appInfo;});
|
||||
this.brands = useAppStore().data.brands();
|
||||
const { locale } = useAppStore().getI18n();
|
||||
this.locale = locale;
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {this.tooltips = Tooltips.make({type:"parent", parentSelector: "#footer"});});
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.tooltips?.closeBootstrapTooltip();
|
||||
},
|
||||
methods: {
|
||||
t(text: string, params: Record<string,string> = {}, localeToUse: string|null = null): string {
|
||||
return ToolboxHelper.trans(text, params, localeToUse);
|
||||
},
|
||||
transField(model: Typei18nModel, field: Typei18nFieldList) {
|
||||
return ToolboxHelper.getTranslatableField(model, field);
|
||||
},
|
||||
getContrastYIQ(hexcolor: string): string {
|
||||
return ToolboxHelper.getContrastYIQ(hexcolor);
|
||||
},
|
||||
gotoBrand(brand: TypeBrand) {
|
||||
useAppStore().setActiveRange(null);
|
||||
useAppStore().setActiveBrand(brand);
|
||||
navigateTo(useLocaleRoute()({"name": "brands-slug", "params": {"slug": this.transField(brand, "slug")}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "swiper/scss";
|
||||
@import "swiper/scss/scrollbar";
|
||||
</style>
|
||||
307
resources/expo/components/layout/AppHeader.vue
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
|
||||
<template>
|
||||
<nav
|
||||
id="top-navbar"
|
||||
class="navbar navbar-expand-lg position-relative p-0"
|
||||
:style="`background-color: ${activeRange && brandWithMultipleRanges ? activeRange.color : activeBrand?.color} !important`"
|
||||
>
|
||||
<div class="container-fluid">
|
||||
<!-- UNE MARQUE EST SELECTIONNEE -->
|
||||
<div
|
||||
v-if="activeBrand"
|
||||
:class="{
|
||||
'd-flex flex-row flex-grow-1 justify-content-center align-items-center': true,
|
||||
'mx-auto': !activeRange,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
:class="{
|
||||
'd-inline-block py-3 px-2 text-center bg-transparent border-0': true,
|
||||
'flex-grow-1': !activeRange
|
||||
}"
|
||||
data-bs-toggle="tooltip"
|
||||
:title="t('view_brand_ranges', {'brand_name': transField(activeBrand, 'name')})"
|
||||
@click="goToBrand(activeBrand)"
|
||||
>
|
||||
<img
|
||||
class="img-fluid main-logo"
|
||||
:src="dataUrl(activeBrand.picture)"
|
||||
:alt="`Marque ${transField(activeBrand, 'name')}`"
|
||||
:title="t('brands_logo', {'brand_name': transField(activeBrand, 'name')})"
|
||||
data-bs-toggle="tooltip"
|
||||
>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeRange && brandWithMultipleRanges"
|
||||
class="flex-grow-1 text-center"
|
||||
>
|
||||
<span
|
||||
:class="{...{
|
||||
'title': true,
|
||||
'ps-md-5 d-inline-block px-5 py-3 rounded text-capitalize': true,
|
||||
'text-dark': getContrastYIQ(activeRange.color) === '#000000',
|
||||
'text-light': getContrastYIQ(activeRange.color) === '#FFFFFF',
|
||||
}, ...rangeFont(activeRange.font_id)}"
|
||||
:style="{
|
||||
'text-shadow': `0 0 0.2rem ${toRGBA(getContrastYIQ(activeRange.color), 0.8)}`
|
||||
}"
|
||||
>
|
||||
{{ transField(activeRange, 'name') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- HOME (PAS DE MARQUE) -->
|
||||
<div
|
||||
v-else
|
||||
class="row text-bg-light"
|
||||
>
|
||||
<div class="col-12 g-0 pb-5">
|
||||
<img
|
||||
src="assets/pictures/layout/top-hr.png"
|
||||
alt="Top bar"
|
||||
class="img-fluid d-block"
|
||||
>
|
||||
</div>
|
||||
<div class="col-12 mx-auto text-center mb-3 mt-3 pb-5">
|
||||
<div class="d-inline-block bg-white p-4 rounded">
|
||||
<img
|
||||
:src="dataUrl(settings.logo)"
|
||||
alt="Logo"
|
||||
class="img-fluid main-logo d-inline-block"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar-menu"
|
||||
type="button"
|
||||
aria-controls="navbar-menu"
|
||||
aria-expanded="false"
|
||||
:aria-label="t('show_hide_languages')"
|
||||
>
|
||||
<span class="navbar-toggler-icon" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="navbar-menu"
|
||||
class="lang-bar navbar-collapse collapse flex-grow-0"
|
||||
>
|
||||
<div class="btn-group z-1">
|
||||
<button
|
||||
v-for="(localeObject, localeIndex) of locales"
|
||||
:key="localeIndex"
|
||||
:class="{
|
||||
'btn ms-3 px-4 mx-0 shadow fw-bold': true,
|
||||
'btn-secondary': locale === localeObject.iso,
|
||||
'btn-white bg-white': locale !== localeObject.iso,
|
||||
}"
|
||||
:title="localeObject.label"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="bottom"
|
||||
@click.prevent="switchLocale(localeObject)"
|
||||
>
|
||||
<span class="text-uppercase fs-5">{{ localeObject.flag }}</span>
|
||||
<span :class="`fi fi-${localeObject.flag}`" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import log from "electron-log";
|
||||
import { defineComponent } from "vue";
|
||||
import type { RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import ToolboxHelper from "@expo/classes/ToolboxHelper";
|
||||
import { useAppStore } from "@expo/stores/useAppStore";
|
||||
import { Tooltips } from "@expo/vueModules/bsTooltips";
|
||||
|
||||
type LocaleParams = {
|
||||
iso: string,
|
||||
flag: string,
|
||||
label: string,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "AppHeader",
|
||||
props: {
|
||||
activeBrand: {
|
||||
type: Object as PropType<TypeBrand|null>,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
activeRange: {
|
||||
type: Object as PropType<TypeRange|null>,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
},
|
||||
data(): {
|
||||
versions: typeof window.versions;
|
||||
settings: TypeSettings;
|
||||
locales: Array<LocaleParams>,
|
||||
locale: string;
|
||||
route: RouteLocationNormalizedLoaded;
|
||||
tooltips: Tooltips|null;
|
||||
} {
|
||||
return {
|
||||
versions: window.versions,
|
||||
settings: {} as TypeSettings,
|
||||
locales: [],
|
||||
locale: "",
|
||||
route: useRoute(),
|
||||
tooltips: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
brandWithMultipleRanges(): boolean {
|
||||
if(!this.activeBrand) {
|
||||
return false;
|
||||
}
|
||||
return this.activeBrand.ranges.length > 1;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route(to: RouteLocationNormalizedLoaded) {
|
||||
log.debug(`Swithing to route ${String(to.name)}`);
|
||||
if(to.name?.toString().startsWith("index")) {
|
||||
// * Reset active Brand on main page
|
||||
useAppStore().setActiveBrand(null);
|
||||
}
|
||||
const { locale } = useAppStore().getI18n();
|
||||
this.locale = locale;
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
this.settings = useAppStore().data.settings();
|
||||
const { locale, availableLocales } = useI18n({ useScope: "global"});
|
||||
this.locale = locale.value;
|
||||
const tenantLocales: Array<string> = this.settings.locales ?? [];
|
||||
this.locales = availableLocales
|
||||
.filter(localeStr => tenantLocales.includes(localeStr))
|
||||
.sort((LocA, LocB) => tenantLocales.indexOf(LocA) - tenantLocales.indexOf(LocB))
|
||||
.map(localeStr => {
|
||||
return {
|
||||
iso: localeStr,
|
||||
flag: this.t("_flag", {}, localeStr),
|
||||
label: this.t("_lang", {}, localeStr),
|
||||
};
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {this.tooltips = Tooltips.make({type:"parent", parentSelector: "#top-navbar"});});
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.tooltips?.closeBootstrapTooltip();
|
||||
},
|
||||
methods: {
|
||||
t(text: string, params: Record<string,string> = {}, localeToUse: string|null = null): string {
|
||||
return ToolboxHelper.trans(text, params, localeToUse);
|
||||
},
|
||||
transField(model: Typei18nModel, field: Typei18nFieldList) {
|
||||
return ToolboxHelper.getTranslatableField(model, field);
|
||||
},
|
||||
switchLocale(localeToSwitch:LocaleParams) {
|
||||
const switchLocalePath = useSwitchLocalePath();
|
||||
// Toast.toast(localeToSwitch.label, ToastTypeEnum.default);
|
||||
useAppStore().switchLocale(localeToSwitch.iso);
|
||||
navigateTo(switchLocalePath(localeToSwitch.iso));
|
||||
},
|
||||
rangeFont(fontId: number) {
|
||||
const font = useAppStore().getFont(fontId);
|
||||
const out: Map<string, boolean> = new Map<string, boolean>();
|
||||
if(font) {
|
||||
out.set(`f-${font.font_name}`, true);
|
||||
}
|
||||
return Object.fromEntries(out.entries());
|
||||
},
|
||||
getContrastYIQ(hexcolor: string): string {
|
||||
return ToolboxHelper.getContrastYIQ(hexcolor);
|
||||
},
|
||||
toRGBA(hexcolor: string, alpha: number = 1): string {
|
||||
return ToolboxHelper.toRGBA(hexcolor, alpha);
|
||||
},
|
||||
goToBrand(activeBrand: TypeBrand): void {
|
||||
if(!activeBrand) {
|
||||
return;
|
||||
}
|
||||
useAppStore().setActiveRange(null);
|
||||
navigateTo(useLocaleRoute()({"name": "brands-slug", "params": {"slug": activeBrand.slug[this.locale]}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/vendor/rfs";
|
||||
#top-navbar {
|
||||
.title {
|
||||
@include rfs(2rem, font-size);
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(3rem, font-size);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(5rem, font-size);
|
||||
}
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
@include rfs(1.2rem, font-size);
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(1.8rem, font-size);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(3.2rem, font-size);
|
||||
}
|
||||
}
|
||||
|
||||
.main-logo {
|
||||
@include rfs(7rem, max-height);
|
||||
& {
|
||||
height: 20vh;
|
||||
}
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(15rem, max-height);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(30rem, max-height);
|
||||
}
|
||||
}
|
||||
.fi {
|
||||
@include media-breakpoint-up(md) {
|
||||
@include rfs(2em, line-height);
|
||||
@include rfs(4em, width);
|
||||
}
|
||||
@include media-breakpoint-up(xxl) {
|
||||
@include rfs(3em, line-height);
|
||||
@include rfs(5em, width);
|
||||
}
|
||||
}
|
||||
.lang-bar {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0;
|
||||
}
|
||||
.lang-bar .btn-group {
|
||||
border-radius: 0;
|
||||
}
|
||||
.lang-bar .btn-group button {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-left-radius: 1rem;
|
||||
border-bottom-right-radius: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
resources/expo/composables/dataUrl.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
let cachedBase: string | null = null;
|
||||
|
||||
export function dataBaseUrl(): string {
|
||||
if (cachedBase) {
|
||||
return cachedBase;
|
||||
}
|
||||
let base = "/data/";
|
||||
const fromQuery = new URLSearchParams(window.location.search).get("data");
|
||||
if (fromQuery) {
|
||||
const url = new URL(fromQuery, window.location.origin);
|
||||
if (url.origin === window.location.origin) {
|
||||
base = url.pathname.replace(/\/?$/, "/");
|
||||
}
|
||||
}
|
||||
cachedBase = base;
|
||||
return base;
|
||||
}
|
||||
|
||||
export function dataUrl(path: string | null | undefined): string {
|
||||
return dataBaseUrl() + (path ?? "").replace(/^\//, "");
|
||||
}
|
||||
25
resources/expo/i18n.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { createI18n } from "vue-i18n";
|
||||
|
||||
export const DEFAULT_LOCALE = "fr";
|
||||
export const ALL_LOCALES = ["fr", "en", "es", "de", "nl"];
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale: DEFAULT_LOCALE,
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages: {},
|
||||
});
|
||||
|
||||
const loaders = import.meta.glob("./locales/*.ts");
|
||||
|
||||
export async function loadMessages(locales: Array<string>): Promise<void> {
|
||||
for (const locale of locales) {
|
||||
const loader = loaders[`./locales/${locale}.ts`];
|
||||
if (!loader) {
|
||||
continue;
|
||||
}
|
||||
const module = (await loader()) as { default: Record<string, unknown> };
|
||||
i18n.global.setLocaleMessage(locale, module.default as never);
|
||||
}
|
||||
}
|
||||
19
resources/expo/locales/de.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"_flag": "de",
|
||||
"_lang": "Deutsch",
|
||||
"error_occured": "Ein Fehler ist aufgetreten.",
|
||||
"loading": "Laden",
|
||||
"show_hide_languages": "Sprachen anzeigen/ausblenden",
|
||||
"view_brand_ranges": "Sortimente der Marke {brand_name} anzeigen",
|
||||
"brands_logo": "Logo für die Marke {brand_name}",
|
||||
"no_brands_available": "Keine Marken verfügbar",
|
||||
"view_range_products": "Produkte der Reihe {range_name} anzeigen",
|
||||
"ranges_logo": "Logo für die Produktreihe {range_name}",
|
||||
"no_ranges_available": "Keine Bereiche verfügbar",
|
||||
"more_about_this_product": "Mehr über dieses Produkt",
|
||||
"close_products_modal": "Produktbeschreibung schließen",
|
||||
"products_logo": "Logo für das Produkt {product_name}",
|
||||
"no_products_available": "Keine Produkte verfügbar",
|
||||
"welcome_to_ldc": "Willkommen bei LDC",
|
||||
"discover_our_brands": "Entdecken Sie unsere Marken"
|
||||
};
|
||||
19
resources/expo/locales/en.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"_flag": "gb",
|
||||
"_lang": "English",
|
||||
"error_occured": "An error has occured",
|
||||
"loading": "Loading",
|
||||
"show_hide_languages": "Show / Hide languages",
|
||||
"view_brand_ranges": "View ranges of the brand {brand_name}",
|
||||
"brands_logo": "Logo for the brand {brand_name}",
|
||||
"no_brands_available": "No brands available",
|
||||
"view_range_products": "View products of the range {range_name}",
|
||||
"ranges_logo": "Logo for the range {range_name}",
|
||||
"no_ranges_available": "No ranges available",
|
||||
"more_about_this_product": "More about this product",
|
||||
"close_products_modal": "Close products description",
|
||||
"products_logo": "Logo for the product {product_name}",
|
||||
"no_products_available": "No products available",
|
||||
"welcome_to_ldc": "welcome to LDC",
|
||||
"discover_our_brands": "Discover our brands"
|
||||
};
|
||||
19
resources/expo/locales/es.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"_flag": "es",
|
||||
"_lang": "Español",
|
||||
"error_occured": "Se ha producido un error",
|
||||
"loading": "Loading",
|
||||
"show_hide_languages": "Mostrar / Ocultar idiomas",
|
||||
"view_brand_ranges": "Ver las gamas de la marca {brand_name}",
|
||||
"brands_logo": "Logotipo de la marca {brand_name}",
|
||||
"no_brands_available": "No hay marcas disponibles",
|
||||
"view_range_products": "Ver los productos de la gama {range_name}",
|
||||
"ranges_logo": "Logotipo de la gama {range_name}",
|
||||
"no_ranges_available": "No hay gamas disponibles",
|
||||
"more_about_this_product": "Más información sobre este producto",
|
||||
"close_products_modal": "Cerrar descripción de productos",
|
||||
"products_logo": "Logotipo del producto {product_name}",
|
||||
"no_products_available": "No hay productos disponibles",
|
||||
"welcome_to_ldc": "bienvenido a LDC",
|
||||
"discover_our_brands": "Descubra nuestras marcas"
|
||||
};
|
||||
19
resources/expo/locales/fr.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"_flag": "fr",
|
||||
"_lang": "Francais",
|
||||
"error_occured": "Une erreur est survenue",
|
||||
"loading": "Chargement",
|
||||
"show_hide_languages": "Afficher / Masquer les langues",
|
||||
"view_brand_ranges": "Voir les gammes de la marque {brand_name}",
|
||||
"brands_logo": "Le logo de la marque {brand_name}",
|
||||
"no_brands_available": "Aucune marque disponible",
|
||||
"view_range_products": "Voir les produits de la gamme {range_name}",
|
||||
"ranges_logo": "Le logo de la gamme {range_name}",
|
||||
"no_ranges_available": "Aucune gamme disponible",
|
||||
"more_about_this_product": "En savoir plus sur ce produit",
|
||||
"close_products_modal": "Fermer la description du produit",
|
||||
"products_logo": "Logo du produit {product_name}",
|
||||
"no_products_available": "Aucun produit disponible",
|
||||
"welcome_to_ldc": "Bienvenue chez LDC",
|
||||
"discover_our_brands": "Découvrez nos différentes marques"
|
||||
};
|
||||
19
resources/expo/locales/nl.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default {
|
||||
"_flag": "nl",
|
||||
"_lang": "Nederlands",
|
||||
"error_occured": "Er is een fout opgetreden",
|
||||
"loading": "Bezig met laden",
|
||||
"show_hide_languages": "Talen tonen/verbergen",
|
||||
"view_brand_ranges": "Bekijk het assortiment van het merk {brand_name}",
|
||||
"brands_logo": "Het logo van het merk {brand_name}",
|
||||
"no_brands_available": "Geen merken beschikbaar",
|
||||
"view_range_products": "Bekijk de producten in het {range_name} assortiment",
|
||||
"ranges_logo": "Het logo van het {range_name}-assortiment",
|
||||
"no_ranges_available": "Geen bereik beschikbaar",
|
||||
"more_about_this_product": "Meer informatie over dit product",
|
||||
"close_products_modal": "Productbeschrijving sluiten",
|
||||
"products_logo": "{product_name} productlogo",
|
||||
"no_products_available": "Geen producten beschikbaar",
|
||||
"welcome_to_ldc": "Welkom bij LDC",
|
||||
"discover_our_brands": "Ontdek onze verschillende merken"
|
||||
};
|
||||
71
resources/expo/main.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { config, dom, library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faArrowLeft, faArrowRight, faCheckSquare, faHouse, faSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import * as bootstrap from "bootstrap";
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp, type Component } from "vue";
|
||||
import App from "./App.vue";
|
||||
import { dataUrl } from "./composables/dataUrl";
|
||||
import { i18n, loadMessages } from "./i18n";
|
||||
import { provides } from "./provides";
|
||||
import { router } from "./router";
|
||||
import { setupCompat } from "./nuxt-compat";
|
||||
import "animate.css";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
import { RouterLink } from "vue-router";
|
||||
import NuxtLinkLocale from "./NuxtLinkLocale";
|
||||
|
||||
async function start(): Promise<void> {
|
||||
setupCompat({ router, i18n, provides });
|
||||
const response = await fetch(dataUrl("data.json"), { credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Chargement des données impossible (${response.status})`);
|
||||
}
|
||||
const dataJson = await response.json();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const globals = window as any;
|
||||
globals.ipcRendererObject = {
|
||||
getJsonData: () => Promise.resolve(dataJson),
|
||||
getAppInfo: () => Promise.resolve({
|
||||
version: String(dataJson.version),
|
||||
name: "uwil-expo",
|
||||
licence: "",
|
||||
}),
|
||||
};
|
||||
globals.versions = {
|
||||
node: (): string => "",
|
||||
chrome: (): string => "",
|
||||
electron: (): string => "",
|
||||
isDebug: false,
|
||||
};
|
||||
globals.bootstrap = bootstrap;
|
||||
|
||||
await loadMessages(dataJson.settings.locales);
|
||||
|
||||
config.autoAddCss = false;
|
||||
config.autoReplaceSvg = false;
|
||||
library.add(faSquare, faCheckSquare, faArrowLeft, faArrowRight, faHouse);
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(i18n);
|
||||
app.use(router);
|
||||
app.component("FontAwesomeIcon", FontAwesomeIcon);
|
||||
app.component("NuxtLinkLocale", NuxtLinkLocale);
|
||||
app.component("NuxtLink", RouterLink);
|
||||
Object.assign(app.config.globalProperties, provides);
|
||||
app.config.globalProperties.dataUrl = dataUrl;
|
||||
|
||||
// save global
|
||||
const components = import.meta.glob("./components/**/*.vue", { eager: true });
|
||||
for (const [file, module] of Object.entries(components)) {
|
||||
const name = file.split("/").pop()?.replace(".vue", "") ?? "";
|
||||
app.component(name, (module as { default: Component }).default);
|
||||
}
|
||||
|
||||
app.mount("#expo-app");
|
||||
dom.watch();
|
||||
}
|
||||
|
||||
start();
|
||||
40
resources/expo/nuxt-compat.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { I18n } from "vue-i18n";
|
||||
import type { RouteLocationRaw, Router } from "vue-router";
|
||||
|
||||
type CompatContext = {
|
||||
router: Router;
|
||||
i18n: I18n;
|
||||
provides: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
const context = {} as CompatContext;
|
||||
|
||||
export function setupCompat(values: CompatContext): void {
|
||||
Object.assign(context, values);
|
||||
}
|
||||
|
||||
const currentLocale = (): string => (context.i18n.global.locale as unknown as { value: string }).value;
|
||||
|
||||
export function navigateTo(to: RouteLocationRaw) {
|
||||
return context.router.push(to);
|
||||
}
|
||||
|
||||
export function useLocaleRoute() {
|
||||
return (route: { name: string; params?: Record<string, string> }, locale?: string) => ({
|
||||
...route,
|
||||
name: `${route.name}___${locale ?? currentLocale()}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSwitchLocalePath() {
|
||||
return (locale: string): string => {
|
||||
const current = context.router.currentRoute.value;
|
||||
const baseName = String(current.name ?? "index").split("___")[0];
|
||||
return context.router.resolve({ name: `${baseName}___${locale}`, params: current.params }).fullPath;
|
||||
};
|
||||
}
|
||||
|
||||
export function useNuxtApp() {
|
||||
return context.provides;
|
||||
}
|
||||
209
resources/expo/pages/brands/[slug].vue
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<template>
|
||||
<div class="page-brands-show d-flex">
|
||||
<div class="container justify-content-center d-flex flex-column">
|
||||
<template v-if="brand.ranges">
|
||||
<div
|
||||
v-for="(rangePair, rangePairIndex) of chunkInefficientCustom(brand.ranges, 2)"
|
||||
:key="rangePairIndex"
|
||||
:class="{'row justify-content-center mt-5 pt-5 mb-5 pb-5': true}"
|
||||
>
|
||||
<div
|
||||
v-for="(range, rangeIndex) of rangePair"
|
||||
:key="rangeIndex"
|
||||
class="col text-center"
|
||||
>
|
||||
<button
|
||||
:class="{
|
||||
'd-inline-block btn position-relative': true,
|
||||
}"
|
||||
:title="t('view_range_products', {'range_name': transField(range, 'name')})"
|
||||
data-bs-toggle="tooltip"
|
||||
@click.stop="gotoRange(range)"
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
class="img-fluid"
|
||||
:src="dataUrl(range.picture)"
|
||||
:alt="t('ranges_logo', {'range_name': transField(range, 'name')})"
|
||||
:title="t('ranges_logo', {'range_name': transField(range, 'name')})"
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
:class="{
|
||||
...{
|
||||
'range-title fs-2 fw-bold mt-5 d-inline-block px-5 py-3 rounded shadow text-capitalize animate__animated': true,
|
||||
'text-dark': getContrastYIQ(range.color) === '#000000',
|
||||
'text-light': getContrastYIQ(range.color) === '#FFFFFF',
|
||||
},
|
||||
...titleAnimations(rangeIndex),
|
||||
...rangeFont(range.font_id)
|
||||
}"
|
||||
:style="{
|
||||
'background-color': range.color,
|
||||
'text-shadow': `0 0 0.2rem ${toRGBA(getContrastYIQ(range.color), 0.8)}`,
|
||||
}"
|
||||
>
|
||||
{{ transField(range, 'name') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="row"
|
||||
>
|
||||
<div class="col">
|
||||
<span>{{ t('no_ranges_available') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import log from "electron-log";
|
||||
import { defineComponent } from "vue";
|
||||
import { useRoute, type RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import type AppState from "@expo/classes/AppState";
|
||||
import Toast, { ToastTypeEnum } from "@expo/classes/Toast";
|
||||
import ToolboxHelper from "@expo/classes/ToolboxHelper";
|
||||
import { useAppStore } from "@expo/stores/useAppStore";
|
||||
import { Tooltips } from "@expo/vueModules/bsTooltips";
|
||||
|
||||
export default defineComponent({
|
||||
name: "BrandShow",
|
||||
data(): {
|
||||
appState: AppState;
|
||||
route: RouteLocationNormalizedLoaded;
|
||||
brand: TypeBrand;
|
||||
animateTimeout: NodeJS.Timeout|undefined;
|
||||
animateIndex: number|null;
|
||||
animations: Array<string>;
|
||||
tooltips: Tooltips|null;
|
||||
} {
|
||||
return {
|
||||
appState: {} as AppState,
|
||||
route: useRoute(),
|
||||
brand: {} as TypeBrand,
|
||||
animateTimeout: undefined,
|
||||
animateIndex: null,
|
||||
animations: ["heartBeat", "tada", "shakeX", "rubberBand"],
|
||||
tooltips: null
|
||||
};
|
||||
},
|
||||
beforeMount() {
|
||||
this.appState = useAppStore().data;
|
||||
|
||||
const brand = useAppStore().activeBrand;
|
||||
if(!brand) {
|
||||
Toast.toast("brand", ToastTypeEnum.error, this.t("error_occured"));
|
||||
log.error("No active brand found to display brands-slug");
|
||||
return ;
|
||||
}
|
||||
this.brand = brand;
|
||||
|
||||
const firstRange = this.brand.ranges.at(0);
|
||||
|
||||
// * Goto products view directly if there is only one range
|
||||
if(this.brand.ranges.length === 1 && firstRange) {
|
||||
this.gotoRange(firstRange);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {this.tooltips = Tooltips.make({type:"parent", parentSelector: ".page-brands-show"});});
|
||||
|
||||
// * ANIMATIONS
|
||||
clearTimeout(this.animateTimeout);
|
||||
this.$nextTick(() => {
|
||||
[...document.getElementsByClassName("range-title")].forEach(el => {
|
||||
el.classList.add("animate__bounce");
|
||||
});
|
||||
this.animateTimeout = setTimeout(() => {
|
||||
[...document.getElementsByClassName("range-title")].forEach(el => {
|
||||
el.classList.remove("animate__bounce");
|
||||
});
|
||||
this.setRangeToAnimate();
|
||||
}, ToolboxHelper.randomInteger(4000, 8000));
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.tooltips?.closeBootstrapTooltip();
|
||||
clearTimeout(this.animateTimeout);
|
||||
},
|
||||
methods: {
|
||||
chunkInefficientCustom(ranges: Array<TypeRange>, chunkSize: number):Array<Array<TypeRange>> {
|
||||
const out: Array<Array<TypeRange>> = [];
|
||||
let row: Array<TypeRange> = [];
|
||||
ranges
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.forEach((range) => {
|
||||
// Handle special width=2 case immediately
|
||||
if (range.width === 2) {
|
||||
if (row.length > 0) {
|
||||
out.push(row);
|
||||
row = [];
|
||||
}
|
||||
out.push([range]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to current row
|
||||
row.push(range);
|
||||
|
||||
// Push row if it reaches chunk size
|
||||
if (row.length === chunkSize) {
|
||||
out.push(row);
|
||||
row = [];
|
||||
}
|
||||
});
|
||||
// Push any remaining elements
|
||||
if (row.length > 0) {
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
t(text: string, params: Record<string,string> = {}, localeToUse: string|null = null): string {
|
||||
return ToolboxHelper.trans(text, params, localeToUse);
|
||||
},
|
||||
transField(model: Typei18nModel, field: Typei18nFieldList) {
|
||||
return ToolboxHelper.getTranslatableField(model, field);
|
||||
},
|
||||
titleAnimations(rangeIndex: number) {
|
||||
const animation = this.animations.at(ToolboxHelper.randomInteger(0, this.animations.length -1));
|
||||
const out: Map<string, boolean> = new Map<string, boolean>();
|
||||
out.set(`animate__${animation}`, this.animateIndex === rangeIndex);
|
||||
return Object.fromEntries(out.entries());
|
||||
},
|
||||
rangeFont(fontId: number) {
|
||||
const font = useAppStore().getFont(fontId);
|
||||
const out: Map<string, boolean> = new Map<string, boolean>();
|
||||
if(font) {
|
||||
out.set(`f-${font.font_name}`, true);
|
||||
}
|
||||
return Object.fromEntries(out.entries());
|
||||
},
|
||||
setRangeToAnimate(): void {
|
||||
clearTimeout(this.animateTimeout);
|
||||
this.animateIndex = Math.ceil(Math.random() * this.brand.ranges.length) -1;
|
||||
this.animateTimeout = setTimeout(this.setRangeToAnimate, ToolboxHelper.randomInteger(2000, 6000));
|
||||
},
|
||||
getContrastYIQ(hexcolor: string): string {
|
||||
return ToolboxHelper.getContrastYIQ(hexcolor);
|
||||
},
|
||||
toRGBA(hexcolor: string, alpha: number = 1): string {
|
||||
return ToolboxHelper.toRGBA(hexcolor, alpha);
|
||||
},
|
||||
gotoRange(range: TypeRange) {
|
||||
useAppStore().setActiveRange(range);
|
||||
navigateTo(useLocaleRoute()({"name": "ranges-slug", "params": {"slug": this.transField(range, "slug")}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-brands-show, .page-brands-show>div {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
211
resources/expo/pages/index.vue
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<template>
|
||||
<div :class="{'page-home d-flex': true, 'text-bg-light': multipleBrands}">
|
||||
<div class="container-fluid justify-content-center d-flex flex-column">
|
||||
<div
|
||||
v-if="multipleBrands"
|
||||
class="row"
|
||||
>
|
||||
<div class="col-12 text-center overflow-hidden pb-5">
|
||||
<div class="home-title">
|
||||
{{ t('welcome_to_ldc') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 text-center pb-5">
|
||||
<video
|
||||
class="object-fit-contain w-100"
|
||||
playsinline
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
controls
|
||||
controlsList="nodownload noremoteplayback nofullscreen"
|
||||
>
|
||||
<source
|
||||
src="assets/videos/groupe-ldc-presentation-groupe-V7.webm"
|
||||
type="video/webm"
|
||||
>
|
||||
<!-- <source src="assets/videos/groupe-ldc-presentation-groupe-V7.mp4" type="video/mp4"> -->
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="appState.brands"
|
||||
class="row h-100 align-items-center"
|
||||
>
|
||||
<div
|
||||
v-for="(brand, brandIndex) of appState.brands()"
|
||||
:key="brandIndex"
|
||||
:class="{
|
||||
'align-items-center': true,
|
||||
'col-12': !multipleBrands,
|
||||
'col-12 col-md-6 px-5 my-3': multipleBrands,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'd-flex h-100 my-2': true,
|
||||
'justify-content-start': brandIndex %2 !== 0 && multipleBrands,
|
||||
'justify-content-end': brandIndex %2 === 0 && multipleBrands,
|
||||
'justify-content-center': !multipleBrands,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="d-inline-block text-center"
|
||||
role="button"
|
||||
:title="t('view_brand_ranges', {'brand_name': transField(brand, 'name')})"
|
||||
data-bs-toggle="tooltip"
|
||||
@click="gotoBrand(brand)"
|
||||
>
|
||||
<div class="d-inline-block brand-picture-wrapper shadow">
|
||||
<img
|
||||
class="img-fluid"
|
||||
:src="dataUrl(brand.picture)"
|
||||
:alt="t('brands_logo', {'brand_name': transField(brand, 'name')})"
|
||||
:title="t('brands_logo', {'brand_name': transField(brand, 'name')})"
|
||||
>
|
||||
</div>
|
||||
<!-- :class="{...{'brand-title fw-bold text-capitalize fs-1 animate__animated p-3 mt-5 d-inline-block': true}, ...titleAnimations(brandIndex)}" -->
|
||||
<span
|
||||
class="d-none"
|
||||
:style="{
|
||||
'color': getContrastYIQ(brand.color),
|
||||
'background-color': brand.color
|
||||
}"
|
||||
>
|
||||
{{ transField(brand, 'name') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row"
|
||||
>
|
||||
<div class="col">
|
||||
<span class="h4">{{ t('no_brands_available') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="multipleBrands"
|
||||
class="row my-5 pb-5"
|
||||
>
|
||||
<div class="col py-5 my-5 pb-5">
|
||||
<img
|
||||
src="assets/pictures/layout/home-bottom-banner.png"
|
||||
alt="Bottom Banner"
|
||||
class="img-fluid"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import type AppState from "@expo/classes/AppState";
|
||||
import ToolboxHelper from "@expo/classes/ToolboxHelper";
|
||||
import { useAppStore } from "@expo/stores/useAppStore";
|
||||
import { Tooltips } from "@expo/vueModules/bsTooltips";
|
||||
import { dataUrl as resolveDataUrl } from "@expo/composables/dataUrl";
|
||||
|
||||
export default defineComponent({
|
||||
name: "HomePage",
|
||||
data() : {
|
||||
appState: AppState;
|
||||
animateTimeout: NodeJS.Timeout|undefined;
|
||||
animateIndex: number|null;
|
||||
animations: Array<string>;
|
||||
tooltips: Tooltips|null;
|
||||
multipleBrands: boolean;
|
||||
} {
|
||||
return {
|
||||
appState: {} as AppState,
|
||||
animateTimeout: undefined,
|
||||
animateIndex: null,
|
||||
animations: ["heartBeat", "tada", "shakeX", "rubberBand"],
|
||||
tooltips: null,
|
||||
multipleBrands: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {this.tooltips = Tooltips.make({type:"parent", parentSelector: ".page-home"});});
|
||||
this.appState = useAppStore().data;
|
||||
this.multipleBrands = this.appState.brands().length > 1;
|
||||
|
||||
// * ANIMATIONS
|
||||
clearTimeout(this.animateTimeout);
|
||||
this.$nextTick(() => {
|
||||
[...document.getElementsByClassName("brand-title")].forEach(el => {
|
||||
el.classList.add("animate__bounce");
|
||||
});
|
||||
this.animateTimeout = setTimeout(() => {
|
||||
[...document.getElementsByClassName("brand-title")].forEach(el => {
|
||||
el.classList.remove("animate__bounce");
|
||||
});
|
||||
this.setBrandToAnimate();
|
||||
}, ToolboxHelper.randomInteger(4000, 8000));
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.tooltips?.closeBootstrapTooltip();
|
||||
clearTimeout(this.animateTimeout);
|
||||
},
|
||||
methods: {
|
||||
getContrastYIQ(hexcolor: string): string {
|
||||
return ToolboxHelper.getContrastYIQ(hexcolor);
|
||||
},
|
||||
t(text: string, params: Record<string,string> = {}, localeToUse: string|null = null): string {
|
||||
return ToolboxHelper.trans(text, params, localeToUse);
|
||||
},
|
||||
transField(model: Typei18nModel, field: Typei18nFieldList) {
|
||||
return ToolboxHelper.getTranslatableField(model, field);
|
||||
},
|
||||
setBrandToAnimate(): void {
|
||||
clearTimeout(this.animateTimeout);
|
||||
this.animateIndex = ToolboxHelper.randomInteger(0, this.appState.brands().length - 1);
|
||||
this.animateTimeout = setTimeout(this.setBrandToAnimate, ToolboxHelper.randomInteger(2000, 6000));
|
||||
},
|
||||
titleAnimations(brandIndex: number) {
|
||||
const animation = this.animations.at(ToolboxHelper.randomInteger(0, this.animations.length -1));
|
||||
const out: Map<string, boolean> = new Map<string, boolean>();
|
||||
out.set(`animate__${animation}`, this.animateIndex === brandIndex);
|
||||
return Object.fromEntries(out.entries());
|
||||
},
|
||||
gotoBrand(brand: TypeBrand) {
|
||||
useAppStore().setActiveBrand(brand);
|
||||
navigateTo(useLocaleRoute()({"name": "brands-slug", "params": {"slug": this.transField(brand, "slug")}}));
|
||||
},
|
||||
dataUrl(path: string | null | undefined): string {
|
||||
return resolveDataUrl(path);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../assets/scss/variables";
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/vendor/rfs";
|
||||
|
||||
.page-home, .page-home>div {
|
||||
min-height: 100%;
|
||||
.brand-picture-wrapper {
|
||||
@include rfs(5rem, border-radius);
|
||||
@include rfs(3rem, padding-top);
|
||||
@include rfs(3rem, padding-bottom);
|
||||
@include rfs(8rem, padding-right);
|
||||
@include rfs(8rem, padding-left);
|
||||
|
||||
background-color: $color-white;
|
||||
}
|
||||
.brand {
|
||||
border-radius: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
73
resources/expo/pages/ranges/[slug].vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<div class="page-ranges-show d-flex py-5">
|
||||
<ProductListVue
|
||||
:range="range"
|
||||
@selected-product="activeProduct = $event.product"
|
||||
/>
|
||||
<ProductDescriptionModalVue
|
||||
:show="activeProduct !== null"
|
||||
:product="activeProduct ?? undefined"
|
||||
:range="range"
|
||||
@closed="activeProduct = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import log from "electron-log";
|
||||
import type { DirectiveBinding } from "vue";
|
||||
import { defineComponent } from "vue";
|
||||
import { useRoute, type RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import Toast, { ToastTypeEnum } from "@expo/classes/Toast";
|
||||
import ToolboxHelper from "@expo/classes/ToolboxHelper";
|
||||
import ProductDescriptionModalVue from "@expo/components/ProductDescriptionModal.vue";
|
||||
import ProductListVue from "@expo/components/ProductList.vue";
|
||||
|
||||
const saneHtml = {
|
||||
mounted: (el: HTMLElement, binding: DirectiveBinding) => {
|
||||
el.innerHTML = DOMPurify.sanitize(binding.value);
|
||||
},
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "RangeShow",
|
||||
directives: {
|
||||
saneHtml,
|
||||
},
|
||||
components: { ProductDescriptionModalVue, ProductListVue },
|
||||
props: {},
|
||||
data(): {
|
||||
route: RouteLocationNormalizedLoaded;
|
||||
range: TypeRange;
|
||||
activeProduct: TypeProduct | null;
|
||||
} {
|
||||
return {
|
||||
route: useRoute(),
|
||||
range: {} as TypeRange,
|
||||
activeProduct: null
|
||||
};
|
||||
},
|
||||
beforeMount() {
|
||||
const range = useAppStore().activeRange;
|
||||
if (!range) {
|
||||
Toast.toast("range", ToastTypeEnum.error, this.t("error_occured"));
|
||||
log.error("No active range found to display ranges-slug");
|
||||
return;
|
||||
}
|
||||
this.range = range;
|
||||
},
|
||||
methods: {
|
||||
t(text: string, params: Record<string, string> = {}, localeToUse: string | null = null): string {
|
||||
return ToolboxHelper.trans(text, params, localeToUse);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-ranges-show, .page-ranges-show>div {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
20
resources/expo/provides.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
Alert, Button, Carousel, Collapse, Dropdown, Modal,
|
||||
Offcanvas, Popover, ScrollSpy, Tab, Toast, Tooltip,
|
||||
} from "bootstrap";
|
||||
import mitt from "mitt";
|
||||
import ToolboxHelper from "./classes/ToolboxHelper";
|
||||
|
||||
const emitter = mitt();
|
||||
|
||||
export const provides = {
|
||||
$bus: { $on: emitter.on, $emit: emitter.emit },
|
||||
$ToolboxHelper: ToolboxHelper,
|
||||
$localStorage: {
|
||||
get: (key: string) => localStorage.getItem(key),
|
||||
set: (key: string, value: string) => localStorage.setItem(key, value),
|
||||
},
|
||||
$Alert: Alert, $Button: Button, $Carousel: Carousel, $Collapse: Collapse,
|
||||
$Dropdown: Dropdown, $Modal: Modal, $Offcanvas: Offcanvas, $Popover: Popover,
|
||||
$ScrollSpy: ScrollSpy, $Tab: Tab, $Toast: Toast, $Tooltip: Tooltip,
|
||||
};
|
||||
30
resources/expo/router.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import { ALL_LOCALES, DEFAULT_LOCALE, i18n } from "./i18n";
|
||||
|
||||
const pages = [
|
||||
{ name: "index", path: "", component: () => import("./pages/index.vue") },
|
||||
{ name: "brands-slug", path: "brands/:slug", component: () => import("./pages/brands/[slug].vue") },
|
||||
{ name: "ranges-slug", path: "ranges/:slug", component: () => import("./pages/ranges/[slug].vue") },
|
||||
];
|
||||
|
||||
const routes = ALL_LOCALES.flatMap((locale) => pages.map((page) => {
|
||||
const prefix = locale === DEFAULT_LOCALE ? "" : `/${locale}`;
|
||||
return {
|
||||
name: `${page.name}___${locale}`,
|
||||
path: `${prefix}/${page.path}`.replace(/\/$/, "") || "/",
|
||||
component: page.component,
|
||||
};
|
||||
}));
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
// The active language follows the route
|
||||
router.beforeEach((to) => {
|
||||
const locale = String(to.name ?? "").split("___")[1];
|
||||
if (locale) {
|
||||
(i18n.global.locale as unknown as { value: string }).value = locale;
|
||||
}
|
||||
});
|
||||
8
resources/expo/shims/electron-log.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const log = {
|
||||
debug: (...args: unknown[]) => console.debug(...args),
|
||||
info: (...args: unknown[]) => console.info(...args),
|
||||
warn: (...args: unknown[]) => console.warn(...args),
|
||||
error: (...args: unknown[]) => console.error(...args),
|
||||
};
|
||||
|
||||
export default log;
|
||||
62
resources/expo/stores/useAppStore.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import AppState from "../classes/AppState";
|
||||
|
||||
export const useAppStore = defineStore("appStore", {
|
||||
state: () => ({
|
||||
data: new AppState({} as TypeApp, "fr", "fr"),
|
||||
activeBrand: null,
|
||||
activeRange: null,
|
||||
} as {
|
||||
data: AppState;
|
||||
activeBrand: TypeBrand | null;
|
||||
activeRange: TypeRange | null;
|
||||
}),
|
||||
actions: {
|
||||
setupAppData(dataJson: TypeApp, locale: string, fallbackLocale: string): void {
|
||||
this.data = new AppState(dataJson, locale, fallbackLocale);
|
||||
},
|
||||
setActiveBrand(brand: TypeBrand|null): void {
|
||||
this.activeBrand = brand;
|
||||
},
|
||||
setActiveRange(range: TypeRange|null): void {
|
||||
this.activeRange = range;
|
||||
},
|
||||
getNextRange(): TypeRange | null {
|
||||
if (!this.activeRange) {
|
||||
return null;
|
||||
}
|
||||
const ranges = this.data.ranges(this.data.brandFromRange(this.activeRange));
|
||||
const activeRangeIndex = ranges.findIndex(range => this.activeRange!.id === range.id);
|
||||
return activeRangeIndex === -1 ? null :
|
||||
ranges.at(activeRangeIndex+1) ?? null;
|
||||
},
|
||||
getPreviousRange(): TypeRange| null {
|
||||
if (!this.activeRange) {
|
||||
return null;
|
||||
}
|
||||
const ranges = this.data.ranges(this.data.brandFromRange(this.activeRange));
|
||||
const activeRangeIndex = ranges.findIndex(range => this.activeRange!.id === range.id);
|
||||
return (activeRangeIndex === -1 || (activeRangeIndex-1 < 0)) ? null :
|
||||
ranges.at(activeRangeIndex-1) ?? null;
|
||||
},
|
||||
getSettings(): TypeSettings {
|
||||
return this.data.settings();
|
||||
},
|
||||
getFont(fontId: number): TypeFont | null {
|
||||
const font = this.data.font(fontId);
|
||||
if (!font) {
|
||||
return null;
|
||||
}
|
||||
return font;
|
||||
},
|
||||
getI18n() {
|
||||
return this.data.geti18N();
|
||||
},
|
||||
switchLocale(locale: string): void {
|
||||
this.data.switchLocale(locale);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useAppStore, import.meta.hot));
|
||||
}
|
||||
14
resources/expo/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNEXT", "DOM", "DOM.Iterable"],
|
||||
"baseUrl": "../..",
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@expo/*": ["resources/expo/*"],
|
||||
"assets/*": ["resources/expo/assets/*"],
|
||||
"electron-log": ["resources/expo/shims/electron-log.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.d.ts", "./**/*.vue"]
|
||||
}
|
||||
28
resources/expo/types/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type emmiter from "@expo/plugins/emmiter.ts";
|
||||
|
||||
interface PluginsInjections {
|
||||
$bus: ReturnType<typeof emmiter>
|
||||
}
|
||||
|
||||
declare module "#app" {
|
||||
interface NuxtApp extends PluginsInjections {}
|
||||
}
|
||||
|
||||
declare module "nuxt/dist/app/nuxt" {
|
||||
interface NuxtApp extends PluginsInjections {}
|
||||
}
|
||||
|
||||
declare module "@vue/runtime-core" {
|
||||
interface ComponentCustomProperties extends PluginsInjections {}
|
||||
}
|
||||
|
||||
// 1. Make sure to import 'vue' before declaring augmented types
|
||||
|
||||
// 2. Specify a file with the types you want to augment
|
||||
// Vue has the constructor type in types/vue.d.ts
|
||||
declare module "vue/types/vue" {
|
||||
// 3. Declare augmentation for Vue
|
||||
interface Vue {
|
||||
$bus: string
|
||||
}
|
||||
}
|
||||
182
resources/expo/types/models.d.ts
vendored
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
export { };
|
||||
|
||||
import type { RouteLocation as NRouteLocation } from "vue-router";
|
||||
|
||||
// * --- MODELS From JSON Schema
|
||||
|
||||
type Locale = string;
|
||||
type i18nFieldList = "name" | "slug" | "description" | "sub_title" | "footer_phrase";
|
||||
type i18nField = Record<string, string>;
|
||||
type i18nModel = SettingsModel | BrandModel | RangeModel | ProductModel;
|
||||
|
||||
/** Showroom Bo json data format */
|
||||
type AppModel = {
|
||||
/** Version de l'application */
|
||||
version: string;
|
||||
settings: SettingsModel;
|
||||
items: Array<BrandModel>;
|
||||
fonts: Array<FontModel>;
|
||||
}
|
||||
|
||||
/** Settings from Showroom Bo */
|
||||
type SettingsModel = {
|
||||
/** Langues disponibles dans l'application */
|
||||
locales: Array<string>
|
||||
/** Sous-titre de l'application */
|
||||
sub_title: i18nField;
|
||||
/** Texte en bas de page */
|
||||
footer_phrase: i18nField;
|
||||
/** Logo de l'application, chemin tel que présent dans le zip */
|
||||
logo: string;
|
||||
/** Couleur de l'application (hex color) */
|
||||
color: string;
|
||||
/** Couleur de fond de toutes les pages de la borne (hex color, optionnelle) */
|
||||
background_color?: string | null;
|
||||
/** Image de fond de toutes les pages de la borne (optionnelle), chemin tel que présent dans le zip */
|
||||
background_image?: string | null;
|
||||
/** Temps en secondes avant retour sur la page home lorsque inactif */
|
||||
home_timeout: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/** A font from Showroom Bo */
|
||||
type FontModel = {
|
||||
/** The unique identifier for a font */
|
||||
id: number;
|
||||
/** Nom de la marque */
|
||||
font_name: string;
|
||||
/** Chemin vers fichier de la font, chemin tel que présent dans le zip*/
|
||||
font: string|null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/** A brand from Showroom Bo */
|
||||
type BrandModel = {
|
||||
/** The unique identifier for a brand */
|
||||
id: number;
|
||||
/** Nom de la marque */
|
||||
name: i18nField;
|
||||
/** Slug de la marque */
|
||||
slug: i18nField;
|
||||
/** Couleur de la marque (hex color) */
|
||||
color: string;
|
||||
/** Logo de la marque, chemin tel que présent dans le zip */
|
||||
picture: string;
|
||||
/** Les enfant sont rendus sur un étagere */
|
||||
shelf: boolean;
|
||||
/** Visible ? (présent dans l'export). Toujours à true in Showroom App */
|
||||
status: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
ranges: Array<RangeModel>;
|
||||
};
|
||||
|
||||
/** A range from Showroom Bo */
|
||||
type RangeModel = {
|
||||
/** The unique identifier for a range */
|
||||
id: number;
|
||||
/** Nom de la gamme */
|
||||
name: i18nField;
|
||||
/** Slug de la gamme */
|
||||
slug: i18nField;
|
||||
/** Logo de la gamme, chemin tel que présent dans le zip */
|
||||
picture: string;
|
||||
/** Couleur de la gamme */
|
||||
color: string;
|
||||
/** Largeur de la gamme 1 colonne ou 2 colonnes (pleine largeur)e */
|
||||
width: number;
|
||||
/** Ordre d'affichage de la gamme */
|
||||
order: number;
|
||||
/** Les enfant sont rendus sur un étagere */
|
||||
shelf: boolean;
|
||||
/** Visible ? (présent dans l'export). Toujours à true in Showroom App */
|
||||
status: boolean;
|
||||
/** Clé étrangère de la marque */
|
||||
brand_id: number;
|
||||
/** Clé étrangère de la police */
|
||||
font_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
products: Array<ProductModel>;
|
||||
};
|
||||
|
||||
/** A product from Showroom Bo */
|
||||
type ProductModel = {
|
||||
/** The unique identifier for a product */
|
||||
id: number;
|
||||
/** Nom du produit */
|
||||
name: i18nField;
|
||||
/** Slug du produit */
|
||||
slug: i18nField;
|
||||
/** Description du produit, texte WYSYWYG */
|
||||
description: i18nField;
|
||||
/** Logo du produit, chemin tel que présent dans le zip */
|
||||
picture: string;
|
||||
/** Visible ? (présent dans l'export). Toujours à true in Showroom App */
|
||||
status: boolean;
|
||||
/** Clé étrangère de la gamme */
|
||||
range_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
// * --- END MODELS
|
||||
|
||||
|
||||
type ItemObject = {
|
||||
id: number
|
||||
name: string
|
||||
teaser: string
|
||||
img: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type ModalSettings = {
|
||||
ctaBtnText: string;
|
||||
ctaDisclaimerText: string;
|
||||
ctaSaveBtn: string;
|
||||
}
|
||||
|
||||
type ContactModel = {
|
||||
product_id: number;
|
||||
email: string;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
type DataModel = {
|
||||
logo: {
|
||||
src: string,
|
||||
width: string
|
||||
},
|
||||
title: string,
|
||||
modalSettings: ModalSettings,
|
||||
itemsByLine: number,
|
||||
items: Array<ItemObject>
|
||||
};
|
||||
|
||||
declare global {
|
||||
type Data = DataModel
|
||||
type Item = ItemObject
|
||||
type Settings = ModalSettings;
|
||||
type Contact = ContactModel;
|
||||
|
||||
// * App Models
|
||||
type TypeApp = AppModel;
|
||||
type TypeSettings = SettingsModel;
|
||||
type TypeBrand = BrandModel;
|
||||
type TypeRange = RangeModel;
|
||||
type TypeProduct = ProductModel;
|
||||
type TypeFont = FontModel;
|
||||
|
||||
type Typei18nField = i18nField;
|
||||
type Typei18nFieldList = i18nFieldList;
|
||||
type Typei18nModel = i18nModel;
|
||||
type ToastTypeEnum = ToastType;
|
||||
|
||||
|
||||
// Nuxt
|
||||
type RouteLocation = NRouteLocation;
|
||||
|
||||
}
|
||||
12
resources/expo/types/window.d.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
interface Window {
|
||||
versions: {
|
||||
node(): string;
|
||||
chrome(): string;
|
||||
electron(): string;
|
||||
isDebug: boolean;
|
||||
};
|
||||
ipcRendererObject: {
|
||||
getJsonData(): Promise<TypeApp>;
|
||||
getAppInfo(): Promise<{ version: string; name: string; licence: string }>;
|
||||
};
|
||||
}
|
||||
114
resources/expo/vueModules/bsTooltips.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Tooltip } from "bootstrap";
|
||||
|
||||
|
||||
type ConstructorTypeParent = "parent";
|
||||
type ConstructorTypeDom = "dom";
|
||||
|
||||
interface ParentSelectorTooltips {
|
||||
type: ConstructorTypeParent
|
||||
/** The parent element that contains Dom elements for which tooltips shall be created. */
|
||||
parentSelector: string;
|
||||
}
|
||||
|
||||
interface DomProvidedTooltips {
|
||||
type: ConstructorTypeDom;
|
||||
/** Dom elements for which tooltips shall be created. */
|
||||
elements: HTMLElement | NodeListOf<HTMLElement>;
|
||||
}
|
||||
|
||||
interface TooltipsOptions {
|
||||
/** Tooltip options. */
|
||||
options?: Partial<Tooltip.Options>|null|undefined;
|
||||
}
|
||||
|
||||
export type TooltipsConstructor = TooltipsOptions & (ParentSelectorTooltips|DomProvidedTooltips);
|
||||
|
||||
export class Tooltips {
|
||||
/** BS tooltips instances */
|
||||
private tooltips: Array<Tooltip> = [];
|
||||
|
||||
/** The parent element that contains Dom elements for which tooltips shall be created. */
|
||||
private parentSelector: string | null = null;
|
||||
|
||||
/** Dom elements for which tooltips shall be created. */
|
||||
private elements: Array<HTMLElement> = [];
|
||||
|
||||
/** Tooltip default options. */
|
||||
private static defaultOptions: Partial<Tooltip.Options> = {
|
||||
delay: {
|
||||
show: 1250,
|
||||
hide: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/** Tooltip options. */
|
||||
private options: Partial<Tooltip.Options> = {};
|
||||
|
||||
/**
|
||||
* @param parentSelector
|
||||
* @param options Tooltip options.
|
||||
*/
|
||||
constructor(params: TooltipsConstructor) {
|
||||
if (params.type === "parent") {
|
||||
this.parentSelectorConstructor(params);
|
||||
}
|
||||
if (params.type === "dom") {
|
||||
this.domPrividedTooltipsContructor(params);
|
||||
}
|
||||
}
|
||||
|
||||
public static make(params: TooltipsConstructor = { type: "parent", parentSelector: "body"}
|
||||
) {
|
||||
const fullParams = { ...{ type: "parent", options: {} }, ...params };
|
||||
return new Tooltips(fullParams);
|
||||
}
|
||||
|
||||
private parentSelectorConstructor({ parentSelector, options }: TooltipsOptions & ParentSelectorTooltips) {
|
||||
this.parentSelector = parentSelector;
|
||||
this.options = { ...Tooltips.defaultOptions, ...options };
|
||||
this.initTooltips();
|
||||
}
|
||||
|
||||
private domPrividedTooltipsContructor({ elements, options }: TooltipsOptions & DomProvidedTooltips) {
|
||||
if (elements instanceof HTMLElement) {
|
||||
this.elements = [elements];
|
||||
} else {
|
||||
this.elements = Array.from(elements);
|
||||
}
|
||||
this.options = { ...Tooltips.defaultOptions, ...options };
|
||||
this.initTooltips();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all Bootstrap tooltips and reinstanciate them.
|
||||
*/
|
||||
public refreshTooltips(): void {
|
||||
this.closeBootstrapTooltip();
|
||||
this.initTooltips();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all Bootstrap tooltips for ever.
|
||||
*/
|
||||
public closeBootstrapTooltip(): void {
|
||||
this.tooltips.forEach((tooltip) => {
|
||||
tooltip.hide();
|
||||
// * Prevent pending tooltips beeing shown after we asked to hide them
|
||||
tooltip.disable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize tooltips
|
||||
*/
|
||||
private initTooltips() {
|
||||
this.closeBootstrapTooltip();
|
||||
/** data-bs-toggle tooltips elements */
|
||||
const elements = this.parentSelector ? [].slice
|
||||
.call(document.querySelectorAll(`${this.parentSelector} [data-bs-toggle="tooltip"]`)) :
|
||||
this.elements;
|
||||
this.tooltips = elements.map((tooltipTriggerEl) => {
|
||||
return new Tooltip(tooltipTriggerEl, this.options);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -52,5 +52,7 @@
|
|||
"You are logged in!": "Sie sind eingeloggt!",
|
||||
"You are receiving this email because we received a password reset request for your account.": "Sie erhalten diese E-Mail, weil wir eine Anfrage zur Passwortzurücksetzung für Ihr Konto erhalten haben.",
|
||||
"change_language": "Sprache ändern",
|
||||
"switch_tenant": "Mandanten wechseln"
|
||||
}
|
||||
"switch_tenant": "Mandanten wechseln",
|
||||
"preview_site": "Vorschau der Website",
|
||||
"preview_title": "Vorschau"
|
||||
}
|
||||
|
|
@ -52,5 +52,7 @@
|
|||
"You are logged in!": "You are logged in!",
|
||||
"You are receiving this email because we received a password reset request for your account.": "You are receiving this email because we received a password reset request for your account.",
|
||||
"change_language": "Change language",
|
||||
"switch_tenant": "Switch Tenant"
|
||||
}
|
||||
"switch_tenant": "Switch Tenant",
|
||||
"preview_site": "Site preview",
|
||||
"preview_title": "Preview"
|
||||
}
|
||||
|
|
@ -239,5 +239,10 @@ return [
|
|||
'status' => 'status',
|
||||
'sub_title' => 'subtitle',
|
||||
'footer_phrase' => 'footer sentence',
|
||||
'background_color' => 'background color',
|
||||
'background_color_code' => 'color code',
|
||||
'background_color_picker' => 'color picker',
|
||||
'background_color_helper' => 'Leave empty to apply no color. If a background image is set, it is displayed first.',
|
||||
'background_image' => 'background image',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,5 +52,7 @@
|
|||
"You are logged in!": "¡Has iniciado sesión!",
|
||||
"You are receiving this email because we received a password reset request for your account.": "Está recibiendo este correo electrónico porque recibimos una solicitud de restablecimiento de contraseña para su cuenta.",
|
||||
"change_language": "Cambiar idioma",
|
||||
"switch_tenant": "Cambiar tenant"
|
||||
}
|
||||
"switch_tenant": "Cambiar tenant",
|
||||
"preview_site": "Vista previa del sitio",
|
||||
"preview_title": "Vista previa"
|
||||
}
|
||||
|
|
@ -52,5 +52,7 @@
|
|||
"You are logged in!": "Vous êtes connecté·e !",
|
||||
"You are receiving this email because we received a password reset request for your account.": "Vous recevez cet e-mail car nous avons reçu une demande de réinitialisation de mot de passe pour votre compte.",
|
||||
"change_language": "Changer la langue",
|
||||
"switch_tenant": "Changer de Tenant"
|
||||
}
|
||||
"switch_tenant": "Changer de Tenant",
|
||||
"preview_site": "Aperçu du site",
|
||||
"preview_title": "Aperçu"
|
||||
}
|
||||
|
|
@ -241,5 +241,10 @@ return [
|
|||
'status' => 'visibilité',
|
||||
'sub_title' => 'sous-titre',
|
||||
'footer_phrase' => 'phrase de pied de page',
|
||||
'background_color' => 'couleur de fond',
|
||||
'background_color_code' => 'code couleur',
|
||||
'background_color_picker' => 'sélecteur de couleur',
|
||||
'background_color_helper' => 'Laisser vide pour ne pas appliquer de couleur. Si une image de fond est définie, elle est affichée en priorité.',
|
||||
'background_image' => 'image de fond',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,5 +52,7 @@
|
|||
"You are logged in!": "Je bent ingelogd!",
|
||||
"You are receiving this email because we received a password reset request for your account.": "Je ontvangt deze e-mail omdat we een verzoek tot wachtwoordherstel voor je account hebben ontvangen.",
|
||||
"change_language": "Taal wijzigen",
|
||||
"switch_tenant": "Tenant wisselen"
|
||||
}
|
||||
"switch_tenant": "Tenant wisselen",
|
||||
"preview_site": "Voorbeeld van de site",
|
||||
"preview_title": "Voorbeeld"
|
||||
}
|
||||
|
|
@ -13,3 +13,7 @@ import "./../modules/sweetalert";
|
|||
// CUSTOM
|
||||
import "./base/common";
|
||||
|
||||
// PREVIEW
|
||||
import "./utilities/tenant-preview";
|
||||
|
||||
import "./utilities/background-color";
|
||||
39
resources/ts/bo/utilities/background-color.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
type JsColorInput = HTMLInputElement & {
|
||||
jscolor?: { fromString(value: string): boolean };
|
||||
};
|
||||
|
||||
const HEX_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
function initBackgroundColor(): void {
|
||||
const code = document.getElementById("settingsBackgroundColor");
|
||||
const picker = document.getElementById("settingsBackgroundColorPicker");
|
||||
|
||||
if (!(code instanceof HTMLInputElement) || !(picker instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
const colorPicker: JsColorInput = picker;
|
||||
|
||||
// selecteur to code
|
||||
const copyToCode = (): void => {
|
||||
code.value = colorPicker.value.toUpperCase();
|
||||
};
|
||||
colorPicker.addEventListener("input", copyToCode);
|
||||
colorPicker.addEventListener("change", copyToCode);
|
||||
|
||||
code.addEventListener("input", () => {
|
||||
const value = code.value.trim();
|
||||
if (!HEX_PATTERN.test(value)) {
|
||||
return;
|
||||
}
|
||||
if (colorPicker.jscolor) {
|
||||
colorPicker.jscolor.fromString(value);
|
||||
}
|
||||
colorPicker.value = value;
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initBackgroundColor);
|
||||
} else {
|
||||
initBackgroundColor();
|
||||
}
|
||||
|
|
@ -46,7 +46,8 @@ import {
|
|||
faTrash,
|
||||
faTriangleExclamation,
|
||||
faUndoAlt,
|
||||
faWandMagic
|
||||
faWandMagic,
|
||||
faArrowUpRightFromSquare,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
config.autoReplaceSvg = false;
|
||||
|
|
@ -92,7 +93,8 @@ library.add(faG);
|
|||
library.add(faP);
|
||||
library.add(faTriangleExclamation);
|
||||
library.add(faBuilding);
|
||||
|
||||
library.add(faArrowUpRightFromSquare);
|
||||
|
||||
// Front
|
||||
library.add(faChevronLeft);
|
||||
library.add(faChevronRight);
|
||||
|
|
|
|||
60
resources/ts/bo/utilities/tenant-preview.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
const SCREEN_WIDTH = 1080;
|
||||
const SCREEN_HEIGHT = 1920;
|
||||
|
||||
function initTenantPreview(): void {
|
||||
const openButton = document.getElementById("tenant-preview-open");
|
||||
const container = document.getElementById("tenant-preview-screen");
|
||||
const frame = document.getElementById("tenant-preview-frame");
|
||||
|
||||
if (
|
||||
!(openButton instanceof HTMLButtonElement) ||
|
||||
!(container instanceof HTMLDivElement) ||
|
||||
!(frame instanceof HTMLIFrameElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Displays the frame with resolution
|
||||
const fit = (): void => {
|
||||
const scale = Math.min(container.clientWidth / SCREEN_WIDTH, container.clientHeight / SCREEN_HEIGHT);
|
||||
frame.style.width = `${SCREEN_WIDTH}px`;
|
||||
frame.style.height = `${SCREEN_HEIGHT}px`;
|
||||
frame.style.transform = `translate(-50%, -50%) scale(${scale})`;
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
container.classList.add("d-none");
|
||||
frame.removeAttribute("src");
|
||||
};
|
||||
|
||||
openButton.addEventListener("click", () => {
|
||||
const url = openButton.dataset["previewUrl"];
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
frame.src = url;
|
||||
container.classList.remove("d-none");
|
||||
container.requestFullscreen().catch(() => {
|
||||
// new tab if fullScreen refuse by browser
|
||||
close();
|
||||
window.open(url, "_blank");
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
if (document.fullscreenElement) {
|
||||
fit();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", fit);
|
||||
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initTenantPreview);
|
||||
} else {
|
||||
initTenantPreview();
|
||||
}
|
||||
|
|
@ -93,6 +93,15 @@
|
|||
@endforeach
|
||||
</div>
|
||||
</li>
|
||||
{{-- preview tenant --}}
|
||||
<li class="nav-item border-end">
|
||||
<button class="btn nav-link d-flex align-items-center h-100 flex-row border-0 px-3" id="tenant-preview-open"
|
||||
data-bs-toggle="tooltip" data-bs-placement="bottom" type="button"
|
||||
data-preview-url="{{ route('bo.preview.app', ['tenant' => tenant()]) }}?data={{ urlencode('/' . tenant()->getTenantKey() . '/bo/preview/data/') }}"
|
||||
title="{{ __('preview_site') }}">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square me-1"></i>
|
||||
</button>
|
||||
</li>
|
||||
@endif
|
||||
<!-- Profile -->
|
||||
<li class="nav-item dropdown">
|
||||
|
|
@ -126,3 +135,9 @@
|
|||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@auth
|
||||
<div class="d-none bg-dark position-relative overflow-hidden" id="tenant-preview-screen">
|
||||
<iframe class="border-0 bg-white position-absolute top-50 start-50" id="tenant-preview-frame"
|
||||
title="{{ __('preview_site') }}" allowfullscreen></iframe>
|
||||
</div>
|
||||
@endauth
|
||||
|
|
|
|||
16
resources/views/bo/layouts/preview-app.blade.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csp-nonce" content="{{ $nonce ?? '' }}">
|
||||
<title>{{ __('preview_title') }} — {{ tenant('name') }}</title>
|
||||
@vite(['resources/expo/main.ts'])
|
||||
</head>
|
||||
|
||||
<body data-bs-theme="light">
|
||||
<div id="expo-app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -90,6 +90,66 @@
|
|||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{{-- BACKGROUND COLOR --}}
|
||||
<div class="col-12 mb-3">
|
||||
<fieldset class="h-100 border p-3">
|
||||
<legend>{{ Str::ucfirst(__('validation.custom.background_color')) }}</legend>
|
||||
<div class="row">
|
||||
{{-- code --}}
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="h-100 border p-3">
|
||||
<label class="col-form-label" for="settingsBackgroundColor">
|
||||
<b>{{ Str::ucfirst(__('validation.custom.background_color_code')) }}</b>
|
||||
</label>
|
||||
<input class="form-control" id="settingsBackgroundColor" name="background_color" type="text"
|
||||
value="{{ old('background_color', $settingsModel->background_color ?? '') }}"
|
||||
aria-describedby="settingsBackgroundColorHelp" maxlength="7" placeholder="#F5EFE6">
|
||||
@include('modules.input-error', [
|
||||
'inputName' => 'background_color',
|
||||
'helperId' => 'settingsBackgroundColorHelp',
|
||||
'helper' => __('validation.custom.background_color_helper'),
|
||||
])
|
||||
</div>
|
||||
{{-- select --}}
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="h-100 border p-3">
|
||||
@include('bo.partials.inputs.int-color', [
|
||||
'id' => 'settingsBackgroundColorPicker',
|
||||
'label' => Str::ucfirst(__('validation.custom.background_color_picker')),
|
||||
'name' => 'background_color_picker',
|
||||
'required' => false,
|
||||
'value' => $settingsModel->background_color ?? '#FFFFFF',
|
||||
'helper' => null,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{{-- BACKGROUND IMAGE --}}
|
||||
<div class="col-12 pb-3">
|
||||
<fieldset class="h-100 border p-3">
|
||||
<legend>{{ Str::ucfirst(__('validation.custom.background_image')) }}</legend>
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
@include('bo.partials.inputs.varchar-picture', [
|
||||
'id' => 'settingsBackgroundImage',
|
||||
'name' => 'background_image',
|
||||
'width' => 1080,
|
||||
'height' => 1920,
|
||||
'model' => $settingsModel ?? null,
|
||||
'helper' => __('crud.minicrud.dimensions_must_be', [
|
||||
'widthpx' => '1080px',
|
||||
'heightpx' => '1920px',
|
||||
]),
|
||||
'required' => false,
|
||||
'preview' => true,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{{-- COLOR --}}
|
||||
<div class="col-12 col-md-6 mb-3">
|
||||
<fieldset class="h-100 border p-3">
|
||||
|
|
|
|||
|
|
@ -89,5 +89,11 @@ Route::middleware(['web', 'admin-secure'])->name('bo.')->group(function () {
|
|||
// * ACTIVITY LOGS.
|
||||
Route::resource('activity_logs', ActivityLogController::class)->only(['index', 'show']);
|
||||
});
|
||||
|
||||
// * PREVIEW.
|
||||
Route::get('/preview/data/{path}', [TenantController::class, 'previewData'])
|
||||
->where('path', '.*')
|
||||
->name('preview.data');
|
||||
Route::get('/preview/app', [TenantController::class, 'previewApp'])->name('preview.app');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,28 @@
|
|||
"description": "Couleur de l'application",
|
||||
"$ref": "#/$defs/hexcolor"
|
||||
},
|
||||
"background_color": {
|
||||
"description": "Couleur de fond de toutes les pages de la borne",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/hexcolor"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"background_image": {
|
||||
"description": "Image de fond de toutes les pages de la borne",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"home_timeout": {
|
||||
"description": "Temps en secondes avant retour sur la page home lorsque inactif",
|
||||
"type": "integer",
|
||||
|
|
@ -64,4 +86,4 @@
|
|||
"color",
|
||||
"home_timeout"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@
|
|||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["node_modules/*", "node_modules/@types/*"],
|
||||
"@/*": ["resources/assets/*"]
|
||||
"@/*": ["resources/assets/*"],
|
||||
"@expo/*": ["resources/expo/*"]
|
||||
}
|
||||
},
|
||||
"include": ["resources/**/*.js", "resources/**/*.ts", "resources/**/*.vue"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "resources/expo"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import AutoImport from "unplugin-auto-import/vite";
|
||||
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import laravel from "laravel-vite-plugin";
|
||||
|
|
@ -13,6 +14,12 @@ import stylelint from "vite-plugin-stylelint";
|
|||
const env = loadEnv("r", process.cwd(), "");
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__INTLIFY_JIT_COMPILATION__: true,
|
||||
__VUE_OPTIONS_API__: true,
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
|
@ -83,6 +90,7 @@ export default defineConfig({
|
|||
"resources/sass/fo/mail-theme.scss",
|
||||
"resources/sass/fo/fo.scss",
|
||||
"resources/ts/fo/fo.ts",
|
||||
"resources/expo/main.ts",
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
|
|
@ -114,7 +122,7 @@ export default defineConfig({
|
|||
cache: false,
|
||||
chokidar: true,
|
||||
include: ["**/resources/**/*.scss", "**/resources/**/*.css"],
|
||||
exclude: ["node_modules", "vendor"],
|
||||
exclude: ["node_modules", "vendor", "resources/expo"],
|
||||
lintOnStart: false,
|
||||
emitError: true,
|
||||
emitWarning: true,
|
||||
|
|
@ -125,7 +133,7 @@ export default defineConfig({
|
|||
cache: false,
|
||||
extensions: ["js", "ts"],
|
||||
include: "**/*.ts",
|
||||
exclude: ["node_modules", "vendor"],
|
||||
exclude: ["node_modules", "vendor","resources/expo"],
|
||||
lintOnStart: false,
|
||||
emitWarning: true,
|
||||
emitError: true,
|
||||
|
|
@ -137,6 +145,18 @@ export default defineConfig({
|
|||
targets: "last 3 version, not dead, >0.3%"
|
||||
}
|
||||
}),
|
||||
AutoImport({
|
||||
include: [/resources\/expo\/.*\.(ts|vue)$/, /resources\/expo\/.*\.vue\?vue/],
|
||||
imports: [
|
||||
"vue",
|
||||
{ "vue-router": ["useRoute", "useRouter"] },
|
||||
{ pinia: ["defineStore", "acceptHMRUpdate"] },
|
||||
{ "vue-i18n": ["useI18n"] },
|
||||
{ "@expo/nuxt-compat": ["navigateTo", "useLocaleRoute", "useSwitchLocalePath", "useNuxtApp"] },
|
||||
],
|
||||
dirs: ["resources/expo/stores", "resources/expo/composables"],
|
||||
dts: "resources/expo/auto-imports.d.ts",
|
||||
}),
|
||||
vue({
|
||||
isProduction: process.env.NODE_ENV !== "local" ? true : false,
|
||||
exclude: ["node_modules", "vendor"],
|
||||
|
|
@ -165,6 +185,18 @@ export default defineConfig({
|
|||
find: "~",
|
||||
replacement: path.resolve(__dirname, "./node_modules"),
|
||||
},
|
||||
{
|
||||
find: "@expo",
|
||||
replacement: path.resolve(__dirname, "./resources/expo"),
|
||||
},
|
||||
{
|
||||
find: "electron-log",
|
||||
replacement: path.resolve(__dirname, "./resources/expo/shims/electron-log.ts"),
|
||||
},
|
||||
{
|
||||
find: /^assets\//,
|
||||
replacement: path.resolve(__dirname, "./resources/expo/assets") + "/",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||