Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
Abdel LAMRANI
c2b9978c3a reverse size for portrait img 2026-10-09 13:07:41 +02:00
Abdel LAMRANI
f8de715e2a feat: ✨ add setting for handle background 2026-10-09 12:55:16 +02:00
Abdel LAMRANI
047407b999 fix: make one resolution 2026-10-09 09:28:53 +02:00
Abdel LAMRANI
107f069d54 fix linters 2026-10-08 17:05:52 +02:00
Abdel LAMRANI
4e920abde1 feat: ✨ add preview option for tenant 2026-10-08 16:30:13 +02:00
73 changed files with 4542 additions and 235 deletions

View file

@ -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);
}
}

View 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'),
];
}
}

View file

@ -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;
}
}

View file

@ -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 = [];
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
View 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>

View 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);
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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;

View 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;
}
}

View 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
);

View 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";

View 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);
}
}
}
}

View 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
View 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')
}

View 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);
}
}

View 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);
}
}
}

View 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];
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>&nbsp;-&nbsp;
<span>{{ appInfo.name }}</span>&nbsp;-&nbsp;
<span>{{ appInfo.version }}</span>&nbsp;-&nbsp;
<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>

View 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>

View 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
View 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);
}
}

View 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"
};

View 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"
};

View 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"
};

View 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"
};

View 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
View 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();

View 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;
}

View 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>

View 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>

View 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>

View 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
View 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;
}
});

View 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;

View 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));
}

View 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
View 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
View 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
View 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 }>;
};
}

View 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);
});
}
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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',
],
];

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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',
],
];

View file

@ -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"
}

View file

@ -13,3 +13,7 @@ import "./../modules/sweetalert";
// CUSTOM
import "./base/common";
// PREVIEW
import "./utilities/tenant-preview";
import "./utilities/background-color";

View 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();
}

View file

@ -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);

View 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();
}

View file

@ -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

View 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>

View file

@ -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">

View file

@ -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');
});
});

View file

@ -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"
]
}
}

View file

@ -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"]
}

View file

@ -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") + "/",
},
],
},
});