Migrate grateWizard from React/Next.js to native Nuxt integration
- Port all React components to Vue 3 (GwTabs, GwMN, GwCRA, GwCRS, GwMap, GwRelations, GwPerimeterList) - Port hooks to Vue composables (useCesiumProfiles, useSavedPerimeters) - Copy pure TS services and utils (duniter/, ss58, gratewizard utils) - Add Leaflet + Geoman + MarkerCluster dependencies - Serve grateWizard as popup via /gratewizard?popup (layout: false) and info page on /gratewizard (with Librodrome layout) - Remove public/gratewizard-app/ static Next.js export - Refine UI: compact tabs, buttons, inputs, cards, perimeter list - Use Ğ1 breve everywhere, French locale for all dates and amounts - Rename roles: vendeur→offre / acheteur→reçoit le produit ou service - Rename prix→évaluation in all visible text - Add calculated result column in CRA and CRS relation tables - DU/Ğ1 selector uses toggle switch (same as role toggle) - Auto-scroll to monetary data card on polygon selection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/pages/gratewizard" class="sidebar-link" active-class="sidebar-link--active">
|
||||
<div class="i-lucide-sparkles h-4 w-4" />
|
||||
GrateWizard
|
||||
grateWizard
|
||||
</NuxtLink>
|
||||
|
||||
<p class="sidebar-section">Livre</p>
|
||||
|
||||
194
app/components/gratewizard/GwCRA.vue
Normal file
194
app/components/gratewizard/GwCRA.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 pt-4">
|
||||
<div class="card-surface">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<p class="gw-metric">Coefficient relatif à l'ancienneté</p>
|
||||
|
||||
<!-- My seniority -->
|
||||
<div class="flex flex-col items-center gap-1 w-full max-w-80">
|
||||
<label class="gw-text text-sm">Mon ancienneté</label>
|
||||
<input
|
||||
v-model="myDate"
|
||||
type="date"
|
||||
:min="Block0Date"
|
||||
:max="todayDate"
|
||||
class="gw-input w-full"
|
||||
/>
|
||||
<p :class="['gw-text text-center text-sm', { invisible: !myDate }]">
|
||||
{{ getDays(myDate) || 0 }} DUs créés
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div class="flex items-center justify-center gap-2 mt-2">
|
||||
<span class="gw-text text-xs text-right">offre<br />le produit ou service</span>
|
||||
<label class="gw-toggle shrink-0">
|
||||
<input v-model="isSeller" type="checkbox" />
|
||||
<span class="gw-toggle-slider" />
|
||||
</label>
|
||||
<span class="gw-text text-xs">reçoit<br />le produit ou service</span>
|
||||
</div>
|
||||
|
||||
<!-- Other party name + add button -->
|
||||
<div class="flex items-center gap-4 w-full max-w-80">
|
||||
<div class="flex-1">
|
||||
<label class="gw-text text-sm">Nom {{ !isSeller ? 'du receveur' : "de l'offreur" }}</label>
|
||||
<input
|
||||
v-model="otherName"
|
||||
type="text"
|
||||
maxlength="25"
|
||||
class="gw-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isBaseFriend"
|
||||
class="gw-icon-btn self-end mb-0.5"
|
||||
:disabled="!otherName || !otherDate || isFriend"
|
||||
:title="friends.some((f) => f.name === otherName) ? 'Mettre \u00e0 jour' : 'Ajouter relation'"
|
||||
@click="addFriend"
|
||||
>
|
||||
<svg v-if="friends.some((f) => f.name === otherName)" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16" /></svg>
|
||||
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Other party date -->
|
||||
<div class="flex flex-col items-center gap-1 w-full max-w-80">
|
||||
<label class="gw-text text-sm">
|
||||
Ancienneté {{ otherName ? 'de ' + otherName : !isSeller ? 'du receveur' : "de l'offreur" }}
|
||||
</label>
|
||||
<input
|
||||
v-model="otherDate"
|
||||
type="date"
|
||||
:min="Block0Date"
|
||||
:max="todayDate"
|
||||
class="gw-input w-full max-w-64"
|
||||
/>
|
||||
<p :class="['gw-text text-center text-sm', { invisible: !otherDate }]">
|
||||
{{ getDays(otherDate) || 0 }} DUs créés
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="flex items-center gap-3 justify-center">
|
||||
<label class="gw-text text-sm whitespace-nowrap">Évaluation de réf.</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="price"
|
||||
type="number"
|
||||
min="0"
|
||||
:step="Math.ceil(Number(price) / 10 / 2) || 1"
|
||||
placeholder="0.00"
|
||||
class="gw-input w-40 pr-14"
|
||||
@input="price = Math.min(Number(($event.target as HTMLInputElement).value), 9999).toString()"
|
||||
/>
|
||||
<select
|
||||
v-model="currency"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent text-white/40 text-sm cursor-pointer border-0 outline-none"
|
||||
@change="price = '1'"
|
||||
>
|
||||
<option value="DU" class="bg-surface-100">DU</option>
|
||||
<option value="G1" class="bg-surface-100">Ğ1</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discount -->
|
||||
<div class="flex items-center gap-3 justify-center">
|
||||
<label class="gw-text text-sm whitespace-nowrap">Réduction newbie</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="discount"
|
||||
type="number"
|
||||
min="0"
|
||||
:step="Math.ceil(Number(discount) / 10 / 2) || 1"
|
||||
placeholder="0"
|
||||
class="gw-input w-40 pr-8"
|
||||
@input="discount = Math.min(Number(($event.target as HTMLInputElement).value), 99).toString()"
|
||||
/>
|
||||
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 text-sm pointer-events-none">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corrected price -->
|
||||
<p class="gw-title mt-4">
|
||||
Évaluation corrigée : {{ fr(getFinalPrice(isSeller ? myDate : otherDate)) }} {{ currencyDisplay }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Relations table -->
|
||||
<GratewizardGwRelations
|
||||
:items="tableItems"
|
||||
:base-friends="baseFriends"
|
||||
result-label="ÉVAL."
|
||||
@select="(f) => { otherName = f.name; otherDate = f.date; }"
|
||||
@remove="removeFriend"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Block0Date, getDays, getRatio, dateToString, fr, type Friend, type TableFriend } from '~/utils/gratewizard';
|
||||
|
||||
const todayDate = dateToString(new Date());
|
||||
const baseFriends: Friend[] = [
|
||||
{ name: 'Bloc 0', date: Block0Date },
|
||||
{ name: 'Newbie', date: todayDate },
|
||||
];
|
||||
|
||||
const price = useLocalStorage('price', '1');
|
||||
const discount = useLocalStorage('discount', '0');
|
||||
const myDate = useLocalStorage<string | undefined>('myDate', undefined);
|
||||
const isSeller = useLocalStorage('isSeller', true);
|
||||
const currency = useLocalStorage('currency', 'DU');
|
||||
const otherName = useLocalStorage('otherName', '');
|
||||
const otherDate = useLocalStorage<string | undefined>('otherDate', undefined);
|
||||
const friends = useLocalStorage<Friend[]>('friends', []);
|
||||
|
||||
const currencyDisplay = computed(() => currency.value === 'G1' ? '\u011e1' : 'DU');
|
||||
|
||||
function getFinalPrice(date: string | undefined): number {
|
||||
if ((!date && !myDate.value) || (!date && !otherDate.value)) return Number(price.value);
|
||||
const ratio = date
|
||||
? myDate.value && (!isSeller.value || !otherDate.value)
|
||||
? getRatio(date, myDate.value)
|
||||
: getRatio(date, otherDate.value)
|
||||
: 1;
|
||||
const d = Number(discount.value) / 100;
|
||||
const p = Number(price.value);
|
||||
return (1 - d) * p + d * p * ratio;
|
||||
}
|
||||
|
||||
function addFriend() {
|
||||
if (!otherDate.value || !otherName.value) return;
|
||||
if (friends.value.some((f) => f.name === otherName.value)) {
|
||||
friends.value = friends.value.map((f) =>
|
||||
f.name === otherName.value ? { ...f, date: otherDate.value! } : f
|
||||
);
|
||||
} else {
|
||||
friends.value = [...friends.value, { name: otherName.value, date: otherDate.value }];
|
||||
}
|
||||
}
|
||||
|
||||
function removeFriend(name: string) {
|
||||
friends.value = friends.value.filter((f) => f.name !== name);
|
||||
}
|
||||
|
||||
const isBaseFriend = computed(() =>
|
||||
baseFriends.some((f) => f.name === otherName.value && f.date === otherDate.value)
|
||||
);
|
||||
const isFriend = computed(() =>
|
||||
friends.value.some((f) => f.name === otherName.value && f.date === otherDate.value)
|
||||
);
|
||||
|
||||
const tableItems = computed<TableFriend[]>(() => {
|
||||
return baseFriends.concat(friends.value).map((friend) => ({
|
||||
...friend,
|
||||
displayName: friend.name.substring(0, 10),
|
||||
displayDate: new Date(friend.date).toLocaleDateString('fr-FR', { dateStyle: 'short' }),
|
||||
result: fr(getFinalPrice(friend.date)),
|
||||
du: getDays(friend.date),
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
208
app/components/gratewizard/GwCRS.vue
Normal file
208
app/components/gratewizard/GwCRS.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 pt-4">
|
||||
<div class="card-surface">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<p class="gw-metric">Coefficient relatif au solde net</p>
|
||||
|
||||
<!-- Buyer name + add button -->
|
||||
<div class="flex items-center gap-4 w-full max-w-80">
|
||||
<div class="flex-1">
|
||||
<label class="gw-text text-sm">Qui reçoit le produit ou service ?</label>
|
||||
<input
|
||||
v-model="otherName"
|
||||
type="text"
|
||||
maxlength="25"
|
||||
class="gw-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isBaseFriend"
|
||||
class="gw-icon-btn self-end mb-0.5"
|
||||
:disabled="!otherName || !otherDate || isFriend"
|
||||
:title="friends.some((f) => f.name === otherName) ? 'Mettre \u00e0 jour' : 'Ajouter relation'"
|
||||
@click="addFriend"
|
||||
>
|
||||
<svg v-if="friends.some((f) => f.name === otherName)" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16" /></svg>
|
||||
<svg v-else class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Buyer seniority -->
|
||||
<div class="flex flex-col items-center gap-1 w-full max-w-80">
|
||||
<label class="gw-text text-sm">
|
||||
Ancienneté {{ otherName ? 'de ' + otherName : 'du receveur' }}
|
||||
</label>
|
||||
<input
|
||||
v-model="otherDate"
|
||||
type="date"
|
||||
:min="Block0Date"
|
||||
:max="todayDate"
|
||||
class="gw-input w-full max-w-64"
|
||||
/>
|
||||
<p :class="['gw-text text-center text-sm', { invisible: !otherDate }]">
|
||||
{{ seniority || 0 }} DUs créés
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- DU Balance -->
|
||||
<div class="flex items-center gap-3 justify-center">
|
||||
<label class="gw-text text-sm whitespace-nowrap">Solde DU{{ otherName ? ' de ' + otherName : ' du receveur' }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="balance"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
class="gw-input w-40 pr-10"
|
||||
@input="balance = Math.max(0, Number(($event.target as HTMLInputElement).value)).toString()"
|
||||
/>
|
||||
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 text-sm pointer-events-none">DU</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Net balance -->
|
||||
<p v-if="seniority > 0" class="gw-text text-center">
|
||||
Solde net : {{ fr(netBalance, 0) }} DU
|
||||
{{ netBalance < 0
|
||||
? ' (b\u00e9n\u00e9ficie de valeurs \u2192 majoration)'
|
||||
: netBalance > 0
|
||||
? ' (alimente en valeurs \u2192 minoration)'
|
||||
: ' (\u00e9quilibr\u00e9)' }}
|
||||
</p>
|
||||
|
||||
<!-- Reference value -->
|
||||
<div class="flex items-center gap-3 justify-center">
|
||||
<label class="gw-text text-sm whitespace-nowrap">Valeur de réf.</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="refValue"
|
||||
type="number"
|
||||
min="0"
|
||||
:step="Math.ceil(v / 10 / 2) || 1"
|
||||
placeholder="0.00"
|
||||
class="gw-input w-40 pr-14"
|
||||
@input="refValue = Math.min(Number(($event.target as HTMLInputElement).value), 9999).toString()"
|
||||
/>
|
||||
<select
|
||||
v-model="currency"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent text-white/40 text-sm cursor-pointer border-0 outline-none"
|
||||
@change="refValue = '1'"
|
||||
>
|
||||
<option value="DU" class="bg-surface-100">DU</option>
|
||||
<option value="G1" class="bg-surface-100">Ğ1</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weight slider -->
|
||||
<div class="flex items-center gap-3 w-full max-w-80">
|
||||
<span class="gw-text whitespace-nowrap text-sm">Poids</span>
|
||||
<input
|
||||
v-model="weight"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
class="flex-1 h-2 rounded-lg appearance-none cursor-pointer accent-accent"
|
||||
/>
|
||||
<span class="gw-text text-sm w-10 text-right">{{ fr(w) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Formula + result -->
|
||||
<div class="w-full max-w-sm text-center p-4 rounded-lg bg-surface-200">
|
||||
<p class="gw-text text-xs font-mono mb-1">
|
||||
V × (1 + w × (1 − S / C))
|
||||
</p>
|
||||
<template v-if="seniority > 0">
|
||||
<p class="gw-text text-xs font-mono mb-2">
|
||||
{{ fr(v) }} × (1 + {{ fr(w) }} × (1 − {{ fr(s, 0) }} / {{ fr(seniority, 0) }}))
|
||||
= {{ fr(v) }} × {{ fr(factor) }}
|
||||
</p>
|
||||
<p class="gw-title">= {{ fr(correctedValue) }} {{ currencyDisplay }}</p>
|
||||
</template>
|
||||
<p v-else class="gw-text text-xs">Renseigner l'ancienneté pour calculer</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Relations table (shared with CRA) -->
|
||||
<GratewizardGwRelations
|
||||
:items="tableItems"
|
||||
:base-friends="baseFriends"
|
||||
result-label="VALEUR"
|
||||
@select="(f) => { otherName = f.name; otherDate = f.date; }"
|
||||
@remove="removeFriend"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Block0Date, getDays, dateToString, fr, type Friend, type TableFriend } from '~/utils/gratewizard';
|
||||
|
||||
const todayDate = dateToString(new Date());
|
||||
const baseFriends: Friend[] = [{ name: 'Bloc 0', date: Block0Date }];
|
||||
|
||||
// Shared state with CRA (same localStorage keys)
|
||||
const currency = useLocalStorage('currency', 'DU');
|
||||
const friends = useLocalStorage<Friend[]>('friends', []);
|
||||
const otherName = useLocalStorage('otherName', '');
|
||||
const otherDate = useLocalStorage<string | undefined>('otherDate', undefined);
|
||||
|
||||
// CRS-specific state
|
||||
const refValue = useLocalStorage('crs-value', '1');
|
||||
const balance = useLocalStorage('crs-balance', '0');
|
||||
const weight = useLocalStorage('crs-weight', '0.50');
|
||||
|
||||
const currencyDisplay = computed(() => currency.value === 'G1' ? '\u011e1' : 'DU');
|
||||
const seniority = computed(() => getDays(otherDate.value));
|
||||
const s = computed(() => Number(balance.value));
|
||||
const w = computed(() => Number(weight.value));
|
||||
const v = computed(() => Number(refValue.value));
|
||||
const netBalance = computed(() => s.value - seniority.value);
|
||||
|
||||
const correctedValue = computed(() =>
|
||||
seniority.value > 0 ? v.value * (1 + w.value * (1 - s.value / seniority.value)) : v.value
|
||||
);
|
||||
const factor = computed(() =>
|
||||
seniority.value > 0 ? 1 + w.value * (1 - s.value / seniority.value) : 1
|
||||
);
|
||||
|
||||
// Friend management (shared with CRA)
|
||||
function addFriend() {
|
||||
if (!otherDate.value || !otherName.value) return;
|
||||
if (friends.value.some((f) => f.name === otherName.value)) {
|
||||
friends.value = friends.value.map((f) =>
|
||||
f.name === otherName.value ? { ...f, date: otherDate.value! } : f
|
||||
);
|
||||
} else {
|
||||
friends.value = [...friends.value, { name: otherName.value, date: otherDate.value }];
|
||||
}
|
||||
}
|
||||
|
||||
function removeFriend(name: string) {
|
||||
friends.value = friends.value.filter((f) => f.name !== name);
|
||||
}
|
||||
|
||||
const isBaseFriend = computed(() =>
|
||||
baseFriends.some((f) => f.name === otherName.value && f.date === otherDate.value)
|
||||
);
|
||||
const isFriend = computed(() =>
|
||||
friends.value.some((f) => f.name === otherName.value && f.date === otherDate.value)
|
||||
);
|
||||
|
||||
function getCorrectedValueFor(friendDate: string): number {
|
||||
const c = getDays(friendDate);
|
||||
if (c <= 0) return v.value;
|
||||
return v.value * (1 + w.value * (1 - s.value / c));
|
||||
}
|
||||
|
||||
const tableItems = computed<TableFriend[]>(() => {
|
||||
return baseFriends.concat(friends.value).map((f) => ({
|
||||
...f,
|
||||
displayName: f.name.substring(0, 10),
|
||||
displayDate: new Date(f.date).toLocaleDateString('fr-FR', { dateStyle: 'short' }),
|
||||
result: fr(getCorrectedValueFor(f.date)),
|
||||
du: getDays(f.date),
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
272
app/components/gratewizard/GwMN.vue
Normal file
272
app/components/gratewizard/GwMN.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 pt-4">
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="p-8 flex flex-col gap-4">
|
||||
<p class="gw-text">Erreur : {{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Currency selector -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<span class="gw-text text-xs">DU</span>
|
||||
<label class="gw-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="unit === 'G1'"
|
||||
@change="unit = ($event.target as HTMLInputElement).checked ? 'G1' : 'DU'"
|
||||
/>
|
||||
<span class="gw-toggle-slider" />
|
||||
</label>
|
||||
<span class="gw-text text-xs">Ğ1</span>
|
||||
</div>
|
||||
|
||||
<!-- Monetary data card -->
|
||||
<div ref="topRef" class="card-surface">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<p class="gw-metric">Masse monétaire / Membres</p>
|
||||
|
||||
<span v-if="polygon" class="gw-chip">
|
||||
{{ activePerimeterName ?? 'S\u00e9lection manuelle' }}
|
||||
</span>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex flex-col items-center gap-2 py-4">
|
||||
<div class="gw-spinner" />
|
||||
<p class="gw-text text-sm">Connexion au réseau...</p>
|
||||
</div>
|
||||
|
||||
<!-- Data grid -->
|
||||
<div v-else-if="monetary" class="grid grid-cols-2 gap-2 w-full max-w-sm">
|
||||
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
|
||||
<p class="gw-text">M (masse{{ polygon ? ' locale' : '' }})</p>
|
||||
<p class="gw-title mt-1">
|
||||
{{ mnIsLoading ? '...' : formatValue(displayM!, unit, duDaily) }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
|
||||
<p class="gw-text">N (membres{{ polygon ? ' locaux' : '' }})</p>
|
||||
<p class="gw-title mt-1">{{ displayN.toLocaleString('fr-FR') }}</p>
|
||||
</div>
|
||||
<div :class="['text-center p-2 rounded-md text-xs', polygon ? 'bg-accent/10' : 'bg-surface-200']">
|
||||
<p class="gw-text">M / N{{ polygon ? ' (local)' : '' }}</p>
|
||||
<p class="gw-title mt-1">
|
||||
{{ mnIsLoading ? '...' : formatValue(displayMN!, unit, duDaily) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center p-2 rounded-md text-xs bg-surface-200">
|
||||
<p class="gw-text">DU journalier</p>
|
||||
<p class="gw-title mt-1">{{ fr(duDaily) }} Ğ1</p>
|
||||
</div>
|
||||
|
||||
<!-- Extra cells when polygon is active -->
|
||||
<template v-if="polygon && nLocal > 0 && monetary">
|
||||
<div class="text-center p-2 rounded-md text-xs bg-surface-200">
|
||||
<p class="gw-text">M / N (réseau)</p>
|
||||
<p class="gw-title mt-1">{{ formatValue(mnG1, unit, duDaily) }}</p>
|
||||
</div>
|
||||
<div class="text-center p-2 rounded-md text-xs bg-accent/10">
|
||||
<p class="gw-text">Part du réseau</p>
|
||||
<p class="gw-title mt-1">{{ fr(nLocal / monetary.membersCount * 100) }} %</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="col-span-2 text-center">
|
||||
<p class="gw-text">
|
||||
Bloc #{{ monetary.blockNumber }} —
|
||||
{{ new Date(monetary.timestamp).toLocaleDateString('fr-FR', { dateStyle: 'long' }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear selection button -->
|
||||
<div v-if="polygon" class="flex flex-col items-center gap-2">
|
||||
<button
|
||||
class="gw-btn gw-btn-danger"
|
||||
@click="handleClearSelection"
|
||||
>
|
||||
Périmètre monde
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Saved perimeters -->
|
||||
<GratewizardGwPerimeterList
|
||||
:perimeters="perimeters"
|
||||
@load="handleLoadPerimeter"
|
||||
@delete="deletePerimeter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map card -->
|
||||
<div class="card-surface">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<p class="gw-metric">
|
||||
Carte des membres géolocalisés
|
||||
<span v-if="!geoLoading && v1Pubkeys" class="gw-chip ml-2">
|
||||
{{ geoMembers.length.toLocaleString('fr-FR') }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p v-if="geoError" class="gw-text">Erreur Cesium+ : {{ geoError }}</p>
|
||||
|
||||
<!-- Loading map -->
|
||||
<div v-if="geoLoading || !v1Pubkeys" class="flex flex-col items-center gap-2 py-4">
|
||||
<div class="gw-spinner" />
|
||||
<p class="gw-text text-sm">Chargement des profils géolocalisés...</p>
|
||||
</div>
|
||||
|
||||
<!-- Map -->
|
||||
<template v-else>
|
||||
<GratewizardGwMap
|
||||
:members="geoMembers"
|
||||
:clear-trigger="clearTrigger"
|
||||
:load-polygon="loadPolygonTrigger"
|
||||
@polygon-change="handlePolygonChange"
|
||||
/>
|
||||
|
||||
<p v-if="!loading && monetary" class="gw-text">
|
||||
{{ geoMembers.length.toLocaleString('fr-FR') }} membres certifiés géolocalisés sur
|
||||
{{ monetary.membersCount.toLocaleString('fr-FR') }} membres au total
|
||||
</p>
|
||||
|
||||
<!-- Save perimeter form -->
|
||||
<div v-if="polygon" class="flex gap-2 items-center">
|
||||
<input
|
||||
v-model="perimeterName"
|
||||
type="text"
|
||||
placeholder="Nom du périmètre"
|
||||
class="gw-input"
|
||||
@keydown.enter="handleSavePerimeter"
|
||||
/>
|
||||
<button
|
||||
class="gw-btn gw-btn-accent"
|
||||
:disabled="!perimeterName.trim()"
|
||||
@click="handleSavePerimeter"
|
||||
>
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { duniter, type MonetaryData } from '~/services/duniter';
|
||||
import { pointInPolygon, fr, formatValue, countUdSince, type CurrencyUnit } from '~/utils/gratewizard';
|
||||
import type { GeoMember } from '~/composables/useCesiumProfiles';
|
||||
import type { SavedPerimeter } from '~/composables/useSavedPerimeters';
|
||||
|
||||
const unit = useLocalStorage<CurrencyUnit>('gw-currency-unit', 'DU');
|
||||
|
||||
const monetary = ref<MonetaryData | null>(null);
|
||||
const v1Pubkeys = ref<string[] | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const polygon = ref<[number, number][] | null>(null);
|
||||
const clearTrigger = ref(0);
|
||||
const loadPolygonTrigger = ref<{ coords: [number, number][]; name: string } | null>(null);
|
||||
const perimeterName = ref('');
|
||||
const activePerimeterName = ref<string | null>(null);
|
||||
const localMNg1 = ref<number | null>(null);
|
||||
const mnLoading = ref(false);
|
||||
const topRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const { geoMembers, loading: geoLoading, error: geoError } = useCesiumProfiles(v1Pubkeys);
|
||||
const { perimeters, savePerimeter, deletePerimeter } = useSavedPerimeters();
|
||||
|
||||
// Fetch monetary data on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [mon, pubkeys] = await Promise.all([
|
||||
duniter.fetchMonetary(),
|
||||
duniter.fetchMemberPubkeys(),
|
||||
]);
|
||||
monetary.value = mon;
|
||||
v1Pubkeys.value = pubkeys;
|
||||
} catch (e: any) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const duDaily = computed(() => monetary.value ? Number(monetary.value.amount) / 100 : 1);
|
||||
const massG1 = computed(() => monetary.value ? Number(monetary.value.monetaryMass) / 100 : 0);
|
||||
const mnG1 = computed(() => monetary.value && monetary.value.membersCount ? massG1.value / monetary.value.membersCount : 0);
|
||||
|
||||
const localMembers = computed(() => {
|
||||
if (!polygon.value) return [];
|
||||
return geoMembers.value.filter((m) => pointInPolygon(m.lat, m.lon, polygon.value!));
|
||||
});
|
||||
|
||||
const nLocal = computed(() => localMembers.value.length);
|
||||
|
||||
// Compute local M/N based on member seniority
|
||||
watch([polygon, localMembers, monetary], async ([poly, members, mon]) => {
|
||||
if (!poly || members.length === 0 || !mon) {
|
||||
localMNg1.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
mnLoading.value = true;
|
||||
|
||||
try {
|
||||
const pubkeys = members.map((m) => m.pubkey);
|
||||
const joinBlocks = await duniter.fetchMemberJoinBlocks(pubkeys);
|
||||
|
||||
const udBlocks = mon.udBlockNumbers;
|
||||
let totalSeniority = 0;
|
||||
let validCount = 0;
|
||||
|
||||
for (const pk of pubkeys) {
|
||||
const joinBlock = joinBlocks.get(pk);
|
||||
if (joinBlock === undefined) continue;
|
||||
totalSeniority += countUdSince(udBlocks, joinBlock);
|
||||
validCount++;
|
||||
}
|
||||
|
||||
if (validCount > 0) {
|
||||
const localAvgSeniority = totalSeniority / validCount;
|
||||
const globalAvgSeniority =
|
||||
Number(mon.monetaryMass) / (Number(mon.amount) * mon.membersCount);
|
||||
localMNg1.value = mnG1.value * (localAvgSeniority / globalAvgSeniority);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to compute local M/N:', e);
|
||||
} finally {
|
||||
mnLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const displayN = computed(() => polygon.value ? nLocal.value : monetary.value?.membersCount ?? 0);
|
||||
const displayMN = computed(() => polygon.value ? localMNg1.value : mnG1.value);
|
||||
const displayM = computed(() => polygon.value ? (localMNg1.value !== null ? localMNg1.value * nLocal.value : null) : massG1.value);
|
||||
const mnIsLoading = computed(() => polygon.value && (mnLoading.value || localMNg1.value === null));
|
||||
|
||||
function handlePolygonChange(poly: [number, number][] | null, name?: string) {
|
||||
polygon.value = poly;
|
||||
activePerimeterName.value = name ?? null;
|
||||
if (poly) nextTick(() => topRef.value?.scrollIntoView({ behavior: 'smooth' }));
|
||||
}
|
||||
|
||||
function handleClearSelection() {
|
||||
polygon.value = null;
|
||||
activePerimeterName.value = null;
|
||||
localMNg1.value = null;
|
||||
clearTrigger.value++;
|
||||
loadPolygonTrigger.value = null;
|
||||
}
|
||||
|
||||
function handleSavePerimeter() {
|
||||
if (!polygon.value || !perimeterName.value.trim()) return;
|
||||
savePerimeter(perimeterName.value.trim(), polygon.value);
|
||||
perimeterName.value = '';
|
||||
}
|
||||
|
||||
function handleLoadPerimeter(p: SavedPerimeter) {
|
||||
loadPolygonTrigger.value = { coords: p.polygon, name: p.name };
|
||||
}
|
||||
</script>
|
||||
146
app/components/gratewizard/GwMap.client.vue
Normal file
146
app/components/gratewizard/GwMap.client.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="gw-map" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet.markercluster';
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||
import '@geoman-io/leaflet-geoman-free';
|
||||
import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css';
|
||||
import type { GeoMember } from '~/composables/useCesiumProfiles';
|
||||
|
||||
type LoadPolygon = { coords: [number, number][]; name: string } | null;
|
||||
|
||||
const props = defineProps<{
|
||||
members: GeoMember[];
|
||||
clearTrigger?: number;
|
||||
loadPolygon?: LoadPolygon;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
polygonChange: [polygon: [number, number][] | null, name?: string];
|
||||
}>();
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
let cluster: any = null;
|
||||
let polygonLayer: L.Layer | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
map = L.map(containerRef.value).setView([46.5, 2.5], 6);
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}).addTo(map);
|
||||
|
||||
cluster = (L as any).markerClusterGroup();
|
||||
map.addLayer(cluster);
|
||||
|
||||
// Geoman controls
|
||||
if (map.pm) {
|
||||
map.pm.addControls({
|
||||
position: 'topleft',
|
||||
drawCircle: false,
|
||||
drawCircleMarker: false,
|
||||
drawMarker: false,
|
||||
drawPolyline: false,
|
||||
drawText: false,
|
||||
editMode: false,
|
||||
dragMode: false,
|
||||
cutPolygon: false,
|
||||
rotateMode: false,
|
||||
removalMode: false,
|
||||
});
|
||||
map.pm.setLang('fr');
|
||||
}
|
||||
|
||||
map.on('pm:create', (e: any) => {
|
||||
if (polygonLayer) {
|
||||
map!.removeLayer(polygonLayer);
|
||||
}
|
||||
polygonLayer = e.layer;
|
||||
const latlngs = (e.layer as L.Polygon).getLatLngs()[0] as L.LatLng[];
|
||||
emit('polygonChange', latlngs.map((ll) => [ll.lat, ll.lng]));
|
||||
});
|
||||
|
||||
map.on('pm:remove', () => {
|
||||
polygonLayer = null;
|
||||
emit('polygonChange', null);
|
||||
});
|
||||
|
||||
// Populate initial markers
|
||||
updateMarkers(props.members);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
cluster = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear polygon when clearTrigger changes
|
||||
watch(() => props.clearTrigger, () => {
|
||||
if (!props.clearTrigger || !map) return;
|
||||
if (polygonLayer) {
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
map.removeLayer(polygonLayer);
|
||||
polygonLayer = null;
|
||||
map.setView(center, zoom, { animate: false });
|
||||
}
|
||||
if (map.pm) {
|
||||
map.pm.disableDraw();
|
||||
}
|
||||
});
|
||||
|
||||
// Load a saved polygon
|
||||
watch(() => props.loadPolygon, (loadPolygon) => {
|
||||
if (!map || !loadPolygon) return;
|
||||
|
||||
if (polygonLayer) {
|
||||
map.removeLayer(polygonLayer);
|
||||
polygonLayer = null;
|
||||
}
|
||||
|
||||
const latlngs = loadPolygon.coords.map(([lat, lng]) => L.latLng(lat, lng));
|
||||
const poly = L.polygon(latlngs, { color: '#3388ff' }).addTo(map);
|
||||
polygonLayer = poly;
|
||||
map.fitBounds(poly.getBounds(), { padding: [20, 20] });
|
||||
emit('polygonChange', loadPolygon.coords, loadPolygon.name);
|
||||
});
|
||||
|
||||
// Update markers when members change
|
||||
function updateMarkers(members: GeoMember[]) {
|
||||
if (!cluster) return;
|
||||
cluster.clearLayers();
|
||||
const markers = members.map((m) =>
|
||||
L.circleMarker([m.lat, m.lon], {
|
||||
radius: 6,
|
||||
color: '#f59e0b',
|
||||
fillColor: '#f59e0b',
|
||||
fillOpacity: 0.7,
|
||||
}).bindPopup(
|
||||
`<strong>${m.title || m.pubkey.slice(0, 8)}</strong>${m.city ? '<br>' + m.city : ''}`
|
||||
)
|
||||
);
|
||||
cluster.addLayers(markers);
|
||||
}
|
||||
|
||||
watch(() => props.members, (members) => {
|
||||
updateMarkers(members);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gw-map {
|
||||
height: 500px;
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
40
app/components/gratewizard/GwPerimeterList.vue
Normal file
40
app/components/gratewizard/GwPerimeterList.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div v-if="perimeters.length > 0" class="w-full flex flex-col gap-1">
|
||||
<p class="text-xs font-semibold text-white/50 uppercase tracking-wide">Périmètres sauvegardés</p>
|
||||
<div class="flex flex-col gap-0.5 w-full">
|
||||
<div
|
||||
v-for="p in perimeters"
|
||||
:key="p.name"
|
||||
class="gw-perimeter-item"
|
||||
@click="emit('load', p)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="text-accent text-xs shrink-0">📍</span>
|
||||
<span class="text-xs font-medium truncate">{{ p.name }}</span>
|
||||
<span class="text-[10px] text-white/30 shrink-0">
|
||||
{{ new Date(p.createdAt).toLocaleDateString('fr-FR', { dateStyle: 'short' }) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="gw-perimeter-delete"
|
||||
@click.stop="emit('delete', p.name)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SavedPerimeter } from '~/composables/useSavedPerimeters';
|
||||
|
||||
defineProps<{
|
||||
perimeters: SavedPerimeter[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
load: [perimeter: SavedPerimeter];
|
||||
delete: [name: string];
|
||||
}>();
|
||||
</script>
|
||||
131
app/components/gratewizard/GwRelations.vue
Normal file
131
app/components/gratewizard/GwRelations.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="card-surface !p-0">
|
||||
<div class="flex items-center justify-center py-3 px-4">
|
||||
<p class="gw-metric text-sm">Mes relations</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-white/8">
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="px-3 py-2 text-left text-xs font-semibold text-white/60 uppercase cursor-pointer select-none hover:text-white/80 transition-colors"
|
||||
@click="col.sortable && toggleSort(col.key)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{{ col.label }}
|
||||
<span v-if="col.sortable && sortKey === col.key" class="text-accent">
|
||||
{{ sortAsc ? '\u25B2' : '\u25BC' }}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
<th class="px-3 py-2 w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="sortedItems.length === 0">
|
||||
<td :colspan="columns.length + 1" class="px-3 py-3 text-center text-white/40 text-xs">
|
||||
Aucune relation
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(friend, index) in sortedItems"
|
||||
:key="friend.name"
|
||||
class="cursor-pointer border-b border-white/5 transition-colors"
|
||||
:class="index % 2 === 0 ? 'bg-surface-200/50' : ''"
|
||||
@click="emit('select', friend)"
|
||||
>
|
||||
<td class="px-3 py-1.5">{{ friend.displayName }}</td>
|
||||
<td v-if="resultLabel" class="px-3 py-1.5">{{ friend.result }}</td>
|
||||
<td class="px-3 py-1.5">{{ friend.displayDate }}</td>
|
||||
<td class="px-3 py-1.5">{{ friend.du }}</td>
|
||||
<td class="px-3 py-1.5 w-8">
|
||||
<button
|
||||
v-if="!isBaseFriend(friend)"
|
||||
class="text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Effacer relation"
|
||||
@click.stop="emit('remove', friend.name)"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M15 9l-6 6M9 9l6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Block0Date, type TableFriend } from '~/utils/gratewizard';
|
||||
|
||||
const props = defineProps<{
|
||||
items: TableFriend[];
|
||||
baseFriends: { name: string; date: string }[];
|
||||
resultLabel?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [friend: TableFriend];
|
||||
remove: [name: string];
|
||||
}>();
|
||||
|
||||
const sortKey = ref<string>('name');
|
||||
const sortAsc = ref(true);
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = [
|
||||
{ key: 'name', label: 'NOM', sortable: true },
|
||||
];
|
||||
if (props.resultLabel) {
|
||||
cols.push({ key: 'result', label: props.resultLabel, sortable: true });
|
||||
}
|
||||
cols.push(
|
||||
{ key: 'date', label: 'DATE', sortable: true },
|
||||
{ key: 'du', label: 'DU', sortable: true },
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
|
||||
function toggleSort(key: string) {
|
||||
if (sortKey.value === key) {
|
||||
sortAsc.value = !sortAsc.value;
|
||||
} else {
|
||||
sortKey.value = key;
|
||||
sortAsc.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const items = [...props.items];
|
||||
const key = sortKey.value;
|
||||
const dir = sortAsc.value ? 1 : -1;
|
||||
|
||||
return items.sort((a, b) => {
|
||||
const first = a[key];
|
||||
const second = b[key];
|
||||
let cmp = 0;
|
||||
switch (key) {
|
||||
case 'name':
|
||||
cmp = String(first).localeCompare(String(second));
|
||||
break;
|
||||
case 'result':
|
||||
case 'du':
|
||||
cmp = Number(first) - Number(second);
|
||||
break;
|
||||
case 'date':
|
||||
cmp = new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
break;
|
||||
}
|
||||
return cmp * dir;
|
||||
});
|
||||
});
|
||||
|
||||
function isBaseFriend(friend: TableFriend): boolean {
|
||||
return props.baseFriends.some(({ name, date }) => name === friend.name && date === friend.date);
|
||||
}
|
||||
</script>
|
||||
41
app/components/gratewizard/GwTabs.vue
Normal file
41
app/components/gratewizard/GwTabs.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center">
|
||||
<p class="gw-metric pb-3" style="font-size: 1.75rem">grateWizard</p>
|
||||
|
||||
<!-- Tab buttons -->
|
||||
<div class="flex gap-0.5 p-0.5 rounded-full bg-surface-200">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="gw-tab-btn"
|
||||
:class="activeTab === tab.key
|
||||
? 'gw-tab-active'
|
||||
: 'gw-tab-inactive'"
|
||||
:disabled="tab.disabled"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab content with KeepAlive -->
|
||||
<div class="w-full">
|
||||
<KeepAlive>
|
||||
<GratewizardGwMN v-if="activeTab === '1'" />
|
||||
<GratewizardGwCRA v-else-if="activeTab === '2'" />
|
||||
<GratewizardGwCRS v-else-if="activeTab === '3'" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const tabs = [
|
||||
{ key: '1', label: 'M/N local', disabled: false },
|
||||
{ key: '2', label: "A l'anciennet\u00e9", disabled: false },
|
||||
{ key: '3', label: 'Au solde', disabled: false },
|
||||
{ key: '4', label: 'Au volume', disabled: true },
|
||||
];
|
||||
|
||||
const activeTab = ref('1');
|
||||
</script>
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<!-- CTAs -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<UiBaseButton @click="launch">
|
||||
<UiBaseButton :href="url" target="_blank" @click="launch">
|
||||
<div class="i-lucide-external-link mr-2 h-4 w-4" />
|
||||
{{ content?.grateWizardTeaser.cta.launch }}
|
||||
</UiBaseButton>
|
||||
@@ -41,7 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { launch } = useGrateWizard()
|
||||
const { url, launch } = useGrateWizard()
|
||||
const { data: content } = await usePageContent('home')
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user