diff --git a/docs/fr/leaderboard-widget.md b/docs/fr/leaderboard-widget.md
new file mode 100644
index 000000000..5703d292a
--- /dev/null
+++ b/docs/fr/leaderboard-widget.md
@@ -0,0 +1,43 @@
+# Leaderboard Widget Documentation
+
+Accessible sur **Admin** > **Widgets** > **Leaderboards**, cette section permet d'afficher un tableau de classement dans votre be-bop.
+
+![image](https://github.com/user-attachments/assets/d9de6d66-d5ac-4f10-9d26-ab1a923946a4)
+
+## Ajouter un leaderboard
+
+Pour ajouter un leaderbord cliquez sur **add leaderboard**
+
+### 1. **Leadeboard name **
+
+Le nom du leaderboard. Un titre court et descriptif qui résume l'objectif du leaderboard.
+
+### 2. **Mode**
+
+Le mode du leaderboard qui peut etre un nombre d'articles vendu (**TotalProducts**) ou une somme d'argent à collecté (**MoneyAmount**)
+
+### 3. Currency
+
+Un champ **currency** si le mode est **MoneyAmount** qui represente la devise. Utilisé pour l'affichage.
+
+### 4. **Beginning date **
+
+La date à laquelle le leaderboard commence.
+
+### 5. **Ending date**
+
+La date limite de fin du leaderboard.
+
+### 6. **Products**
+
+Une liste de produits dont la vente fait evoluer le leaderboard.
+
+## Intégration leaderboard dans CMS
+
+Pour ajouter un leaderboard dans une page ou zone CMS vous pouvez utiliser `[Leaderboard=slug]`
+
+![image](https://github.com/user-attachments/assets/af0565ec-a927-46fe-9fa4-1bf0039e0b89)
+
+Et ça sera affiché comme suit :
+
+![image](https://github.com/user-attachments/assets/551b7283-edb6-41eb-8d13-1c220c530839)
diff --git a/src/lib/components/CmsDesign.svelte b/src/lib/components/CmsDesign.svelte
index f1a0bbeff..25b8e5334 100644
--- a/src/lib/components/CmsDesign.svelte
+++ b/src/lib/components/CmsDesign.svelte
@@ -19,7 +19,8 @@
CmsTokens,
CmsContactForm,
CmsCountdown,
- CmsGallery
+ CmsGallery,
+ CmsLeaderboard
} from '$lib/server/cms';
import SpecificationWidget from './SpecificationWidget.svelte';
import ContactForm from './ContactForm.svelte';
@@ -27,6 +28,7 @@
import CountdownWidget from './CountdownWidget.svelte';
import GalleryWidget from './GalleryWidget/GalleryWidget.svelte';
import { page } from '$app/stores';
+ import LeaderBoardWidget from './LeaderBoardWidget.svelte';
import CurrencyCalculator from './CurrencyCalculator.svelte';
export let products: CmsProduct[];
@@ -45,6 +47,7 @@
export let websiteLink: string | undefined;
export let brandName: string | undefined;
export let galleries: CmsGallery[];
+ export let leaderboards: CmsLeaderboard[];
let classNames = '';
export { classNames as class };
@@ -64,6 +67,10 @@
digitalFiles.map((digitalFile) => [digitalFile.productId, digitalFile])
);
$: challengeById = Object.fromEntries(challenges.map((challenge) => [challenge._id, challenge]));
+ $: leaderboardById = Object.fromEntries(
+ leaderboards.map((leaderboard) => [leaderboard._id, leaderboard])
+ );
+
$: sliderById = Object.fromEntries(sliders.map((slider) => [slider._id, slider]));
$: tagById = Object.fromEntries(tags.map((tag) => [tag._id, tag]));
$: picturesByTag = groupBy(
@@ -182,6 +189,13 @@
: ''}{token.height ? `height: ${token.height}px;` : ''}"
/>
+ {:else if token.type === 'leaderboardWidget'}
+
{:else if token.type === 'currencyCalculatorWidget'}
{:else if token.type === 'html'}
@@ -258,6 +272,13 @@
/>
{:else if token.type === 'pictureWidget'}
+ {:else if token.type === 'leaderboardWidget'}
+
{:else if token.type === 'currencyCalculatorWidget'}
{:else if token.type === 'html'}
diff --git a/src/lib/components/CmsPage.svelte b/src/lib/components/CmsPage.svelte
index 1d8ae43b4..553ba8c0b 100644
--- a/src/lib/components/CmsPage.svelte
+++ b/src/lib/components/CmsPage.svelte
@@ -12,7 +12,8 @@
CmsSpecification,
CmsContactForm,
CmsCountdown,
- CmsGallery
+ CmsGallery,
+ CmsLeaderboard
} from '$lib/server/cms';
import CmsDesign from './CmsDesign.svelte';
@@ -43,6 +44,8 @@
export let contactForms: CmsContactForm[];
export let countdowns: CmsCountdown[];
export let galleries: CmsGallery[];
+ export let leaderboards: CmsLeaderboard[];
+
const { t } = useI18n();
@@ -76,6 +79,7 @@
{brandName}
{countdowns}
{galleries}
+ {leaderboards}
class="body body-mainPlan"
/>
{:else}
@@ -102,6 +106,7 @@
{brandName}
{countdowns}
{galleries}
+ {leaderboards}
class="body"
/>
diff --git a/src/lib/components/GoalProgress.svelte b/src/lib/components/GoalProgress.svelte
index 0f06ea3d7..801fdd561 100644
--- a/src/lib/components/GoalProgress.svelte
+++ b/src/lib/components/GoalProgress.svelte
@@ -2,10 +2,11 @@
export let text: string;
export let goal: number;
export let progress: number;
+ export let leaderboard = false;
let className = '';
export { className as class };
- $: percentage = (progress * 100) / goal;
+ $: percentage = goal !== 0 ? (progress * 100) / goal : 0;
$: newPercentage = (goal * 100) / progress;
@@ -15,7 +16,7 @@
? 'bg-green-500'
: 'bg-gradient-to-r from-red-500 to-green-500 via-yellow-500'}"
>
- {#if percentage < 100}
+ {#if percentage <= 100}
= 100 ? `width: calc(${Math.round(newPercentage)}%);` : ''}
>
diff --git a/src/lib/components/LeaderBoardWidget.svelte b/src/lib/components/LeaderBoardWidget.svelte
new file mode 100644
index 000000000..8af5f5ae5
--- /dev/null
+++ b/src/lib/components/LeaderBoardWidget.svelte
@@ -0,0 +1,60 @@
+
+
+{#each leaderboard.progress as progress}
+
+
+{/each}
diff --git a/src/lib/server/cms.ts b/src/lib/server/cms.ts
index bdd49bb41..3703e1b0c 100644
--- a/src/lib/server/cms.ts
+++ b/src/lib/server/cms.ts
@@ -14,6 +14,7 @@ import type { Tag } from '$lib/types/Tag';
import type { ContactForm } from '$lib/types/ContactForm';
import type { Countdown } from '$lib/types/Countdown';
import type { Gallery } from '$lib/types/Gallery';
+import type { Leaderboard } from '$lib/types/Leaderboard';
const window = new JSDOM('').window;
@@ -77,6 +78,11 @@ type TokenObject =
display: string | undefined;
raw: string;
}
+ | {
+ type: 'leaderboardWidget';
+ slug: string;
+ raw: string;
+ }
| { type: 'currencyCalculatorWidget'; slug: string; raw: string };
export async function cmsFromContent(
@@ -86,6 +92,7 @@ export async function cmsFromContent(
const PRODUCT_WIDGET_REGEX =
/\[Product=(?
[\p{L}\d_-]+)(?:[?\s]display=(?[a-z0-9-]+))?\]/giu;
const CHALLENGE_WIDGET_REGEX = /\[Challenge=(?[a-z0-9-]+)\]/giu;
+ const LEADERBOARD_WIDGET_REGEX = /\[Leaderboard=(?[a-z0-9-]+)\]/giu;
const SLIDER_WIDGET_REGEX =
/\[Slider=(?[\p{L}\d_-]+)(?:[?\s]autoplay=(?[\d]+))?\]/giu;
const TAG_WIDGET_REGEX =
@@ -112,6 +119,7 @@ export async function cmsFromContent(
const countdownFormSlugs = new Set();
const tagProductsSlugs = new Set();
const gallerySlugs = new Set();
+ const leaderboardSlugs = new Set();
const currencyCalculatorSlugs = new Set();
const tokens: {
@@ -143,6 +151,7 @@ export async function cmsFromContent(
...matchAndSort(content, COUNTDOWN_WIDGET_REGEX, 'countdownWidget'),
...matchAndSort(content, TAG_PRODUCTS_REGEX, 'tagProducts'),
...matchAndSort(content, GALLERY_WIDGET_REGEX, 'galleryWidget'),
+ ...matchAndSort(content, LEADERBOARD_WIDGET_REGEX, 'leaderboardWidget'),
...matchAndSort(content, CURRENCY_CALCULATOR_WIDGET_REGEX, 'currencyCalculatorWidget')
].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const match of matches) {
@@ -250,6 +259,14 @@ export async function cmsFromContent(
raw: match[0]
});
break;
+ case 'leaderboardWidget':
+ leaderboardSlugs.add(match.groups.slug);
+ token.push({
+ type: 'leaderboardWidget',
+ slug: match.groups.slug,
+ raw: match[0]
+ });
+ break;
case 'currencyCalculatorWidget':
currencyCalculatorSlugs.add(match.groups.slug);
token.push({
@@ -277,149 +294,210 @@ export async function cmsFromContent(
locals.user?.roleId === POS_ROLE_ID
? { 'actionSettings.retail.visible': true }
: { 'actionSettings.eShop.visible': true };
+ const leaderboards =
+ leaderboardSlugs.size > 0
+ ? await collections.leaderboards
+ .find({
+ _id: { $in: [...leaderboardSlugs] }
+ })
+ .project>(
+ {
+ name: 1,
+ goal: 1,
+ progress: 1,
+ endsAt: 1,
+ beginsAt: 1,
+ mode: 1
+ }
+ )
+ .toArray()
+ : [];
+ const allProductsLead = leaderboards
+ .flatMap((leaderboard) => leaderboard.progress || [])
+ .map((progressItem) => progressItem.productId);
- const products = await collections.products
- .find({
- $or: [{ tagIds: { $in: [...tagProductsSlugs] } }, { _id: { $in: [...productSlugs] } }],
- ...query
- })
- .project<
- Pick<
- Product,
- | '_id'
- | 'price'
- | 'name'
- | 'shortDescription'
- | 'preorder'
- | 'availableDate'
- | 'type'
- | 'shipping'
- | 'actionSettings'
- | 'stock'
- | 'tagIds'
- | 'alias'
- | 'isTicket'
- | 'hasSellDisclaimer'
- >
- >({
- price: 1,
- shortDescription: locals.language
- ? { $ifNull: [`$translations.${locals.language}.shortDescription`, '$shortDescription'] }
- : 1,
- preorder: 1,
- name: locals.language ? { $ifNull: [`$translations.${locals.language}.name`, '$name'] } : 1,
- availableDate: 1,
- type: 1,
- shipping: 1,
- actionSettings: 1,
- stock: 1,
- tagIds: 1,
- alias: 1,
- isTicket: 1,
- hasSellDisclaimer: 1
- })
- .toArray();
- const challenges = await collections.challenges
- .find({
- _id: { $in: [...challengeSlugs] }
- })
- .project<
- Pick
- >({
- name: 1,
- goal: 1,
- progress: 1,
- endsAt: 1,
- beginsAt: 1,
- mode: 1
- })
- .toArray();
- const sliders = await collections.sliders
- .find({
- _id: { $in: [...sliderSlugs] }
- })
- .toArray();
+ const products =
+ tagProductsSlugs.size > 0 || productSlugs.size > 0 || allProductsLead.length > 0
+ ? await collections.products
+ .find({
+ $or: [
+ { tagIds: { $in: [...tagProductsSlugs] } },
+ { _id: { $in: [...productSlugs, ...allProductsLead] } }
+ ],
+ ...query
+ })
+ .project<
+ Pick<
+ Product,
+ | '_id'
+ | 'price'
+ | 'name'
+ | 'shortDescription'
+ | 'preorder'
+ | 'availableDate'
+ | 'type'
+ | 'shipping'
+ | 'actionSettings'
+ | 'stock'
+ | 'tagIds'
+ | 'alias'
+ | 'isTicket'
+ | 'hasSellDisclaimer'
+ >
+ >({
+ price: 1,
+ shortDescription: locals.language
+ ? {
+ $ifNull: [
+ `$translations.${locals.language}.shortDescription`,
+ '$shortDescription'
+ ]
+ }
+ : 1,
+ preorder: 1,
+ name: locals.language
+ ? { $ifNull: [`$translations.${locals.language}.name`, '$name'] }
+ : 1,
+ availableDate: 1,
+ type: 1,
+ shipping: 1,
+ actionSettings: 1,
+ stock: 1,
+ tagIds: 1,
+ alias: 1,
+ isTicket: 1,
+ hasSellDisclaimer: 1
+ })
+ .toArray()
+ : [];
+ const challenges =
+ challengeSlugs.size > 0
+ ? await collections.challenges
+ .find({
+ _id: { $in: [...challengeSlugs] }
+ })
+ .project<
+ Pick
+ >({
+ name: 1,
+ goal: 1,
+ progress: 1,
+ endsAt: 1,
+ beginsAt: 1,
+ mode: 1
+ })
+ .toArray()
+ : [];
+ const sliders =
+ sliderSlugs.size > 0
+ ? await collections.sliders
+ .find({
+ _id: { $in: [...sliderSlugs] }
+ })
+ .toArray()
+ : [];
+
+ const digitalFiles =
+ products.length > 0
+ ? await collections.digitalFiles
+ .find({ productId: { $in: products.map((product) => product._id) } })
+ .project>({
+ name: 1,
+ productId: 1
+ })
+ .sort({ createdAt: 1 })
+ .toArray()
+ : [];
+ const tags =
+ tagSlugs.size > 0
+ ? await collections.tags
+ .find({
+ _id: { $in: [...tagSlugs] }
+ })
+ .project<
+ Pick
+ >({
+ name: 1,
+ title: { $ifNull: [`$translations.${locals.language}.title`, '$title'] },
+ subtitle: { $ifNull: [`$translations.${locals.language}.subtitle`, '$subtitle'] },
+ content: { $ifNull: [`$translations.${locals.language}.content`, '$content'] },
+ shortContent: {
+ $ifNull: [`$translations.${locals.language}.shortContent`, '$shortContent']
+ },
+ cta: { $ifNull: [`$translations.${locals.language}.cta`, '$cta'] }
+ })
+ .toArray()
+ : [];
+ const specifications =
+ specificationSlugs.size > 0
+ ? await collections.specifications
+ .find({
+ _id: { $in: [...specificationSlugs] }
+ })
+ .project>({
+ title: { $ifNull: [`$translations.${locals.language}.title`, '$title'] },
+ content: { $ifNull: [`$translations.${locals.language}.content`, '$content'] }
+ })
+ .toArray()
+ : [];
+ const contactForms =
+ contactFormSlugs.size > 0
+ ? await collections.contactForms
+ .find({
+ _id: { $in: [...contactFormSlugs] }
+ })
+ .project<
+ Pick<
+ ContactForm,
+ | '_id'
+ | 'content'
+ | 'target'
+ | 'subject'
+ | 'displayFromField'
+ | 'prefillWithSession'
+ | 'disclaimer'
+ >
+ >({
+ content: { $ifNull: [`$translations.${locals.language}.content`, '$content'] },
+ target: 1,
+ displayFromField: 1,
+ prefillWithSession: 1,
+ subject: { $ifNull: [`$translations.${locals.language}.subject`, '$subject'] },
+ disclaimer: { $ifNull: [`$translations.${locals.language}.disclaimer`, '$disclaimer'] }
+ })
+ .toArray()
+ : [];
+ const countdowns =
+ countdownFormSlugs.size > 0
+ ? await collections.countdowns
+ .find({
+ _id: { $in: [...countdownFormSlugs] }
+ })
+ .project>({
+ title: {
+ $ifNull: [`$translations.${locals.language}.title`, '$title']
+ },
+ description: {
+ $ifNull: [`$translations.${locals.language}.description`, '$description']
+ },
+ endsAt: 1
+ })
+ .toArray()
+ : [];
+ const galleries =
+ gallerySlugs.size > 0
+ ? await collections.galleries
+ .find({
+ _id: { $in: [...gallerySlugs] }
+ })
+ .project>({
+ name: 1,
+ principal: { $ifNull: [`$translations.${locals.language}.principal`, '$principal'] },
+ secondary: { $ifNull: [`$translations.${locals.language}.secondary`, '$secondary'] }
+ })
+ .toArray()
+ : [];
- const digitalFiles = await collections.digitalFiles
- .find({ productId: { $in: products.map((product) => product._id) } })
- .project>({
- name: 1,
- productId: 1
- })
- .sort({ createdAt: 1 })
- .toArray();
- const tags = await collections.tags
- .find({
- _id: { $in: [...tagSlugs] }
- })
- .project>(
- {
- name: 1,
- title: { $ifNull: [`$translations.${locals.language}.title`, '$title'] },
- subtitle: { $ifNull: [`$translations.${locals.language}.subtitle`, '$subtitle'] },
- content: { $ifNull: [`$translations.${locals.language}.content`, '$content'] },
- shortContent: {
- $ifNull: [`$translations.${locals.language}.shortContent`, '$shortContent']
- },
- cta: { $ifNull: [`$translations.${locals.language}.cta`, '$cta'] }
- }
- )
- .toArray();
- const specifications = await collections.specifications
- .find({
- _id: { $in: [...specificationSlugs] }
- })
- .project>({
- title: { $ifNull: [`$translations.${locals.language}.title`, '$title'] },
- content: { $ifNull: [`$translations.${locals.language}.content`, '$content'] }
- })
- .toArray();
- const contactForms = await collections.contactForms
- .find({
- _id: { $in: [...contactFormSlugs] }
- })
- .project<
- Pick<
- ContactForm,
- | '_id'
- | 'content'
- | 'target'
- | 'subject'
- | 'displayFromField'
- | 'prefillWithSession'
- | 'disclaimer'
- >
- >({
- content: { $ifNull: [`$translations.${locals.language}.content`, '$content'] },
- target: 1,
- displayFromField: 1,
- prefillWithSession: 1,
- subject: { $ifNull: [`$translations.${locals.language}.subject`, '$subject'] },
- disclaimer: { $ifNull: [`$translations.${locals.language}.disclaimer`, '$disclaimer'] }
- })
- .toArray();
- const countdowns = await collections.countdowns
- .find({
- _id: { $in: [...countdownFormSlugs] }
- })
- .project>({
- title: {
- $ifNull: [`$translations.${locals.language}.title`, '$title']
- },
- description: { $ifNull: [`$translations.${locals.language}.description`, '$description'] },
- endsAt: 1
- })
- .toArray();
- const galleries = await collections.galleries
- .find({
- _id: { $in: [...gallerySlugs] }
- })
- .project>({
- name: 1,
- principal: { $ifNull: [`$translations.${locals.language}.principal`, '$principal'] },
- secondary: { $ifNull: [`$translations.${locals.language}.secondary`, '$secondary'] }
- })
- .toArray();
return {
tokens,
challenges,
@@ -430,6 +508,7 @@ export async function cmsFromContent(
contactForms,
countdowns,
galleries,
+ leaderboards,
pictures: await collections.pictures
.find({
$or: [
@@ -469,3 +548,4 @@ export type CmsContactForm = Awaited>['contact
export type CmsCountdown = Awaited>['countdowns'][number];
export type CmsGallery = Awaited>['galleries'][number];
export type CmsToken = Awaited>['tokens']['desktop'][number];
+export type CmsLeaderboard = Awaited>['leaderboards'][number];
diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts
index 8c0abb902..33520a5cd 100644
--- a/src/lib/server/database.ts
+++ b/src/lib/server/database.ts
@@ -46,6 +46,7 @@ import type { Gallery } from '$lib/types/Gallery';
import type { VatProfile } from '$lib/types/VatProfile';
import type { Ticket } from '$lib/types/Ticket';
import type { OrderLabel } from '$lib/types/OrderLabel';
+import type { Leaderboard } from '$lib/types/Leaderboard';
// Bigger than the default 10, helpful with MongoDB errors
Error.stackTraceLimit = 100;
@@ -85,6 +86,7 @@ const genCollection = () => ({
nostrReceivedMessages: db.collection('nostr.receivedMessage'),
cmsPages: db.collection('cmsPages'),
challenges: db.collection('challenges'),
+ leaderboards: db.collection('leaderboards'),
roles: db.collection('roles'),
users: db.collection('users'),
discounts: db.collection('discounts'),
@@ -189,7 +191,8 @@ const indexes: Array<[Collection, IndexSpecification, CreateIndexesOptions?
[collections.personalInfo, { 'user.ssoIds': 1 }],
[collections.tickets, { orderId: 1 }],
[collections.tickets, { productId: 1 }],
- [collections.tickets, { ticketId: 1 }, { unique: true }]
+ [collections.tickets, { ticketId: 1 }, { unique: true }],
+ [collections.leaderboards, { beginsAt: 1, endsAt: 1 }]
];
export async function createIndexes() {
diff --git a/src/lib/server/orders.ts b/src/lib/server/orders.ts
index ebe62a377..aad80ae0d 100644
--- a/src/lib/server/orders.ts
+++ b/src/lib/server/orders.ts
@@ -21,7 +21,7 @@ import { toSatoshis } from '$lib/utils/toSatoshis';
import { currentWallet, getNewAddress, orderAddressLabel } from './bitcoind';
import { lndCreateInvoice } from './lnd';
import { ORIGIN } from '$env/static/private';
-import { emailsEnabled } from './email';
+import { emailsEnabled, queueEmail } from './email';
import { sum } from '$lib/utils/sum';
import { computeDeliveryFees, type Cart, computePriceInfo } from '$lib/types/Cart';
import { CURRENCY_UNIT, FRACTION_DIGITS_PER_CURRENCY, type Currency } from '$lib/types/Currency';
@@ -1553,41 +1553,97 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
);
}
if (items.length) {
- const content = `Dear be-BOP owner,
-
- The order #${order.number} ${ORIGIN}/order/${order._id} was successfully paid.
-
- It contains the following product(s) that increase the challenge ${challenge.name} :
- ${items
- .map(
- (item) =>
- `- ${item.product.name} - price ${
- item.customPrice?.amount || item.product.price.amount
- } ${item.customPrice?.currency || item.product.price.currency} - qty ${
- item.quantity
- } - total addition to challenge: ${
- challenge.mode === 'totalProducts'
- ? item.quantity
- : (item.customPrice?.amount || item.product.price.amount) * item.quantity
- }`
- )
- .join('\n')}
-
- Total increase : ${increase}
-
- Challenge current level : ${challenge.progress}`;
- await collections.emailNotifications.insertOne({
- _id: new ObjectId(),
- createdAt: new Date(),
- updatedAt: new Date(),
- subject: 'Challenge Update',
- htmlContent: content,
- dest: runtimeConfig.sellerIdentity?.contact.email || SMTP_USER
- });
+ await queueEmail(
+ runtimeConfig.sellerIdentity?.contact.email || SMTP_USER,
+ 'order.update.challenge',
+ {
+ challengeName: challenge.name,
+ orderNumber: `${order.number}`,
+ orderLink: `${ORIGIN}/order/${order._id}`,
+ itemsChallenge: `${items
+ .map(
+ (item) =>
+ `- ${item.product.name} - price ${
+ item.customPrice?.amount || item.product.price.amount
+ } ${item.customPrice?.currency || item.product.price.currency} - qty ${
+ item.quantity
+ } - total addition to challenge: ${
+ challenge.mode === 'totalProducts'
+ ? item.quantity
+ : (item.customPrice?.amount || item.product.price.amount) * item.quantity
+ }`
+ )
+ .join('\n')}`,
+ increase: `${increase}`,
+ challengeLevel: `${challenge.progress}`
+ }
+ );
+ }
+ }
+ //#endregion
+ //#region leaderboard
+ const leaderboards = await collections.leaderboards
+ .find({
+ beginsAt: { $lt: new Date() },
+ endsAt: { $gt: new Date() }
+ })
+ .toArray();
+ for (const leaderboard of leaderboards) {
+ const productIds = new Set(leaderboard.productIds);
+ const items = order.items.filter((item) => productIds.has(item.product._id));
+ for (const item of items) {
+ const increase =
+ leaderboard.mode === 'totalProducts'
+ ? item.quantity
+ : toCurrency(
+ leaderboard.progress[0].currency || 'SAT',
+ (item.customPrice?.amount || item.product.price.amount) * item.quantity,
+ item.customPrice?.currency || item.product.price.currency
+ );
+ await collections.leaderboards.updateOne(
+ { _id: leaderboard._id, 'progress.productId': item.product._id },
+ {
+ $inc: { 'progress.$.amount': increase },
+ $push: {
+ event: {
+ type: 'progress',
+ at: new Date(),
+ orderId: order._id,
+ amount: increase,
+ productId: item.product._id
+ }
+ }
+ },
+ { session }
+ );
+ }
+ if (items.length) {
+ await queueEmail(
+ runtimeConfig.sellerIdentity?.contact.email || SMTP_USER,
+ 'order.update.leaderboard',
+ {
+ leaderboardName: leaderboard.name,
+ orderNumber: `${order.number}`,
+ orderLink: `${ORIGIN}/order/${order._id}`,
+ itemsLeaderboard: `${items
+ .map(
+ (item) =>
+ `- ${item.product.name} - price ${
+ item.customPrice?.amount || item.product.price.amount
+ } ${item.customPrice?.currency || item.product.price.currency} - qty ${
+ item.quantity
+ } - total addition to leaderboard: ${
+ leaderboard.mode === 'totalProducts'
+ ? item.quantity
+ : (item.customPrice?.amount || item.product.price.amount) * item.quantity
+ }`
+ )
+ .join('\n')}`
+ }
+ );
}
}
//#endregion
-
//#region tickets
let i = 0;
for (const item of order.items) {
diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts
index 8dbbef1e9..7516a75c3 100644
--- a/src/lib/server/runtime-config.ts
+++ b/src/lib/server/runtime-config.ts
@@ -267,6 +267,24 @@ Amount: {{amount}} {{currency}}`,
html: `Payment for order #{{orderNumber}} is paid, see {{orderLink}}
Order #{{orderNumber}} is not fully paid yet.
`,
default: true as boolean
+ },
+ 'order.update.challenge': {
+ subject: 'Leaderboard {{challengeName}',
+ html: `Dear be-BOP owner,
+The order #{{orderNumber}} {{orderLink}} was successfully paid.
+It contains the following product(s) that increase the challenge {{challengeName}} :
+{{itemsChallenge}}
+Total increase : {{increase}}
+Challenge current level : {{challengeLevel}}
`,
+ default: true as boolean
+ },
+ 'order.update.leaderboard': {
+ subject: 'Leaderboard {{leaderboardName}}',
+ html: `Dear be-BOP owner,
+The order #{{orderNumber}} {{orderLink}} was successfully paid.
+It contains the following product(s) that increase the leaderboard {{leaderboardName}} :
+{{itemsLeaderboard}}
`,
+ default: true as boolean
}
}
};
diff --git a/src/lib/types/Leaderboard.ts b/src/lib/types/Leaderboard.ts
new file mode 100644
index 000000000..9343dacfb
--- /dev/null
+++ b/src/lib/types/Leaderboard.ts
@@ -0,0 +1,28 @@
+import type { Currency } from './Currency';
+import type { Order } from './Order';
+import type { Product } from './Product';
+import type { Timestamps } from './Timestamps';
+
+export type Leaderboard = Timestamps & {
+ _id: string;
+ name: string;
+
+ /* If empty, works on all products */
+ productIds: string[];
+
+ beginsAt: Date;
+ endsAt: Date;
+ mode: 'moneyAmount' | 'totalProducts';
+ progress: {
+ productId: Product['_id'];
+ amount: number;
+ currency?: Currency;
+ }[];
+ event?: {
+ type: 'progress';
+ at: Date;
+ orderId: Order['_id'];
+ productId: Product['_id'];
+ amount: number;
+ }[];
+};
diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte
index 4eb12b5ba..a8709843e 100644
--- a/src/routes/(app)/+page.svelte
+++ b/src/routes/(app)/+page.svelte
@@ -23,6 +23,7 @@
brandName={data.brandName}
countdowns={data.cmsData.countdowns}
galleries={data.cmsData.galleries}
+ leaderboards={data.cmsData.leaderboards}
/>
{:else}
diff --git a/src/routes/(app)/[slug]/+page.svelte b/src/routes/(app)/[slug]/+page.svelte
index 7d87b19ac..5c8f1a72f 100644
--- a/src/routes/(app)/[slug]/+page.svelte
+++ b/src/routes/(app)/[slug]/+page.svelte
@@ -21,4 +21,5 @@
sessionEmail={data.email}
countdowns={data.cmsData.countdowns}
galleries={data.cmsData.galleries}
+ leaderboards={data.cmsData.leaderboards}
/>
diff --git a/src/routes/(app)/[slug]/[sub]/[...catchall]/+page.svelte b/src/routes/(app)/[slug]/[sub]/[...catchall]/+page.svelte
index 7d87b19ac..5c8f1a72f 100644
--- a/src/routes/(app)/[slug]/[sub]/[...catchall]/+page.svelte
+++ b/src/routes/(app)/[slug]/[sub]/[...catchall]/+page.svelte
@@ -21,4 +21,5 @@
sessionEmail={data.email}
countdowns={data.cmsData.countdowns}
galleries={data.cmsData.galleries}
+ leaderboards={data.cmsData.leaderboards}
/>
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/adminLinks.ts b/src/routes/(app)/admin[[hash=admin_hash]]/adminLinks.ts
index 647a7aa08..9d365dd57 100644
--- a/src/routes/(app)/admin[[hash=admin_hash]]/adminLinks.ts
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/adminLinks.ts
@@ -188,6 +188,10 @@ export const adminLinks: AdminLinks = [
{
href: '/admin/gallery',
label: 'Galleries'
+ },
+ {
+ href: '/admin/leaderboard',
+ label: 'Leaderboards'
}
]
}
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/+page.server.ts b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/+page.server.ts
new file mode 100644
index 000000000..386247a4f
--- /dev/null
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/+page.server.ts
@@ -0,0 +1,9 @@
+import { collections } from '$lib/server/database';
+
+export const load = async () => {
+ const leaderboards = await collections.leaderboards.find({}).sort({ updatedAt: -1 }).toArray();
+
+ return {
+ leaderboards
+ };
+};
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/+page.svelte b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/+page.svelte
new file mode 100644
index 000000000..9fd6d56c1
--- /dev/null
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/+page.svelte
@@ -0,0 +1,20 @@
+
+
+Add leaderboard
+
+List of leaderboards
+
+ {#each data.leaderboards as leaderboard}
+ -
+
+ {leaderboard.name}
+
+ -
+ [Leaderboard={leaderboard._id}]
+
+ {:else}
+ No leaderboards yet
+ {/each}
+
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/[id]/+page.server.ts b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/[id]/+page.server.ts
new file mode 100644
index 000000000..ab9aa3702
--- /dev/null
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/[id]/+page.server.ts
@@ -0,0 +1,111 @@
+import { adminPrefix } from '$lib/server/admin.js';
+import { collections } from '$lib/server/database';
+import { CURRENCIES, parsePriceAmount } from '$lib/types/Currency';
+import { MAX_NAME_LIMIT, type Product } from '$lib/types/Product';
+import { error, redirect } from '@sveltejs/kit';
+import { set } from 'lodash-es';
+import type { JsonObject } from 'type-fest';
+import { z } from 'zod';
+
+export async function load({ params }) {
+ const leaderboard = await collections.leaderboards.findOne({
+ _id: params.id
+ });
+
+ if (!leaderboard) {
+ throw error(404, 'leaderboard not found');
+ }
+
+ const beginsAt = leaderboard.beginsAt;
+ const endsAt = leaderboard.endsAt;
+ const products = await collections.products
+ .find({})
+ .project>({ name: 1 })
+ .toArray();
+
+ return {
+ leaderboard,
+ beginsAt,
+ endsAt,
+ products
+ };
+}
+
+export const actions = {
+ update: async function ({ request, params }) {
+ const leaderboard = await collections.leaderboards.findOne({
+ _id: params.id
+ });
+
+ if (!leaderboard) {
+ throw error(404, 'leaderboard not found');
+ }
+
+ const formData = await request.formData();
+ const json: JsonObject = {};
+ for (const [key, value] of formData) {
+ set(json, key, value);
+ }
+
+ // We don't allow changing the currency, or the mode
+ const { name, progress, beginsAt, endsAt, progressChanged } = z
+ .object({
+ name: z.string().min(1).max(MAX_NAME_LIMIT),
+ progress: z.array(
+ z.object({
+ productId: z.string().trim(),
+ amount: z
+ .string()
+ .regex(/^\d+(\.\d+)?$/)
+ .default('0'),
+ currency: z.enum([CURRENCIES[0], ...CURRENCIES.slice(1)]).default('SAT')
+ })
+ ),
+ progressChanged: z.boolean({ coerce: true }),
+ beginsAt: z.date({ coerce: true }),
+ endsAt: z.date({ coerce: true })
+ })
+ .parse({
+ ...json,
+ productIds: JSON.parse(String(formData.get('productIds'))).map(
+ (x: { value: string }) => x.value
+ )
+ });
+ const progressParsed = progress
+ .filter((prog) => leaderboard.productIds.includes(prog.productId))
+ .map((prog) => ({
+ ...prog,
+ amount: Math.max(parsePriceAmount(prog.amount, prog.currency), 0)
+ }));
+
+ const updateResult = await collections.leaderboards.updateOne(
+ {
+ _id: leaderboard._id
+ },
+ {
+ $set: {
+ name,
+ ...(progressChanged && { progress: progressParsed }),
+ beginsAt,
+ endsAt,
+ updatedAt: new Date()
+ }
+ }
+ );
+
+ if (!updateResult.matchedCount && progressChanged) {
+ throw error(
+ 409,
+ "A new order was made in parallel which updated the leaderboard's progress. Please try again"
+ );
+ }
+ },
+
+ delete: async function ({ params }) {
+ await collections.leaderboards.deleteOne({
+ _id: params.id
+ });
+
+ throw redirect(303, `${adminPrefix()}/leaderboard`);
+ }
+};
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/[id]/+page.svelte b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/[id]/+page.svelte
new file mode 100644
index 000000000..807507589
--- /dev/null
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/[id]/+page.svelte
@@ -0,0 +1,168 @@
+
+
+Edit a leaderboard
+
+
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/new/+page.server.ts b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/new/+page.server.ts
new file mode 100644
index 000000000..49587f66d
--- /dev/null
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/new/+page.server.ts
@@ -0,0 +1,69 @@
+import { collections } from '$lib/server/database';
+import type { Actions } from './$types';
+import { error, redirect } from '@sveltejs/kit';
+import { z } from 'zod';
+import { MAX_NAME_LIMIT, type Product } from '$lib/types/Product';
+import { generateId } from '$lib/utils/generateId';
+import { adminPrefix } from '$lib/server/admin';
+import { CURRENCIES } from '$lib/types/Currency';
+
+export const load = async () => {
+ const products = await collections.products
+ .find({})
+ .project>({ name: 1 })
+ .toArray();
+
+ return {
+ products
+ };
+};
+
+export const actions: Actions = {
+ default: async function ({ request }) {
+ const data = await request.formData();
+
+ const { name, mode, productIds, currency, beginsAt, endsAt } = z
+ .object({
+ name: z.string().min(1).max(MAX_NAME_LIMIT),
+ productIds: z.string().array(),
+ mode: z.enum(['totalProducts', 'moneyAmount']),
+ currency: z.enum([CURRENCIES[0], ...CURRENCIES.slice(1)]).optional(),
+ beginsAt: z.date({ coerce: true }),
+ endsAt: z.date({ coerce: true })
+ })
+ .parse({
+ name: data.get('name'),
+ productIds: JSON.parse(String(data.get('productIds'))).map(
+ (x: { value: string }) => x.value
+ ),
+ mode: data.get('mode'),
+ currency: data.get('currency') ?? undefined,
+ beginsAt: data.get('beginsAt'),
+ endsAt: data.get('endsAt')
+ });
+
+ if (mode === 'moneyAmount' && !currency) {
+ throw error(400, 'Currency is required');
+ }
+
+ const slug = generateId(name, true);
+
+ await collections.leaderboards.insertOne({
+ _id: slug,
+ name,
+ productIds: productIds,
+ progress: productIds.map((productId) => ({
+ productId: productId,
+ amount: 0,
+ ...(mode === 'moneyAmount' && { currency: currency })
+ })),
+ beginsAt,
+ endsAt,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ mode
+ });
+
+ throw redirect(303, `${adminPrefix()}/leaderboard/${slug}`);
+ }
+};
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/new/+page.svelte b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/new/+page.svelte
new file mode 100644
index 000000000..b521b2092
--- /dev/null
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/leaderboard/new/+page.svelte
@@ -0,0 +1,101 @@
+
+
+Add a leaderboard
+
+
diff --git a/src/routes/(app)/cart/+page.svelte b/src/routes/(app)/cart/+page.svelte
index 4ca1661ed..eb2995e90 100644
--- a/src/routes/(app)/cart/+page.svelte
+++ b/src/routes/(app)/cart/+page.svelte
@@ -65,6 +65,7 @@
sessionEmail={data.email}
countdowns={data.cmsBasketTopData.countdowns}
galleries={data.cmsBasketTopData.galleries}
+ leaderboards={data.cmsBasketTopData.leaderboards}
class={data.hideCmsZonesOnMobile ? 'hidden lg:contents' : ''}
/>
{/if}
@@ -368,6 +369,7 @@
sessionEmail={data.email}
countdowns={data.cmsBasketBottomData.countdowns}
galleries={data.cmsBasketBottomData.galleries}
+ leaderboards={data.cmsBasketBottomData.leaderboards}
class={data.hideCmsZonesOnMobile ? 'hidden lg:contents' : ''}
/>
{/if}
diff --git a/src/routes/(app)/checkout/+page.svelte b/src/routes/(app)/checkout/+page.svelte
index 83befe066..750a929c8 100644
--- a/src/routes/(app)/checkout/+page.svelte
+++ b/src/routes/(app)/checkout/+page.svelte
@@ -181,6 +181,7 @@
sessionEmail={data.email}
countdowns={data.cmsCheckoutTopData.countdowns}
galleries={data.cmsCheckoutTopData.galleries}
+ leaderboards={data.cmsCheckoutTopData.leaderboards}
class={data.hideCmsZonesOnMobile ? 'hidden lg:contents' : ''}
/>
{/if}
@@ -1093,6 +1094,7 @@
sessionEmail={data.email}
countdowns={data.cmsCheckoutBottomData.countdowns}
galleries={data.cmsCheckoutBottomData.galleries}
+ leaderboards={data.cmsCheckoutBottomData.leaderboards}
class={data.hideCmsZonesOnMobile ? 'hidden lg:contents' : ''}
/>
{/if}
diff --git a/src/routes/(app)/leaderboards/[id]/+page.server.ts b/src/routes/(app)/leaderboards/[id]/+page.server.ts
new file mode 100644
index 000000000..bc14d003f
--- /dev/null
+++ b/src/routes/(app)/leaderboards/[id]/+page.server.ts
@@ -0,0 +1,48 @@
+import { error } from '@sveltejs/kit';
+import { collections } from '$lib/server/database';
+import type { Leaderboard } from '$lib/types/Leaderboard';
+import type { Product } from '$lib/types/Product';
+
+export const load = async ({ params, locals }) => {
+ const leaderboard = await collections.leaderboards.findOne<
+ Pick
+ >(
+ { _id: params.id },
+ {
+ projection: {
+ _id: 1,
+ name: 1,
+ progress: 1,
+ endsAt: 1,
+ beginsAt: 1,
+ mode: 1
+ }
+ }
+ );
+
+ if (!leaderboard) {
+ throw error(404, 'leaderboard not found');
+ }
+ const products = await collections.products
+ .find({
+ _id: { $in: [...leaderboard.progress.map((prog) => prog.productId)] }
+ })
+ .project>({
+ name: locals.language ? { $ifNull: [`$translations.${locals.language}.name`, '$name'] } : 1,
+ shortDescription: locals.language
+ ? { $ifNull: [`$translations.${locals.language}.shortDescription`, '$shortDescription'] }
+ : 1
+ })
+ .toArray();
+ const pictures = await collections.pictures
+ .find({
+ productId: { $in: [...products.map((product) => product._id)] }
+ })
+ .sort({ createdAt: 1 })
+ .toArray();
+ return {
+ leaderboard,
+ products,
+ pictures
+ };
+};
diff --git a/src/routes/(app)/leaderboards/[id]/+page.svelte b/src/routes/(app)/leaderboards/[id]/+page.svelte
new file mode 100644
index 000000000..856c3182c
--- /dev/null
+++ b/src/routes/(app)/leaderboards/[id]/+page.svelte
@@ -0,0 +1,18 @@
+
+
+
+
+ {data.leaderboard.name}
+
+
+
diff --git a/src/routes/(app)/order/[id]/+page.svelte b/src/routes/(app)/order/[id]/+page.svelte
index 2c368cdf0..75c9c4179 100644
--- a/src/routes/(app)/order/[id]/+page.svelte
+++ b/src/routes/(app)/order/[id]/+page.svelte
@@ -87,6 +87,7 @@
sessionEmail={data.email}
countdowns={data.cmsOrderTopData.countdowns}
galleries={data.cmsOrderTopData.galleries}
+ leaderboards={data.cmsOrderTopData.leaderboards}
class={data.hideCmsZonesOnMobile ? 'hidden lg:contents' : ''}
/>
{/if}
@@ -630,6 +631,7 @@
sessionEmail={data.email}
countdowns={data.cmsOrderBottomData.countdowns}
galleries={data.cmsOrderBottomData.galleries}
+ leaderboards={data.cmsOrderBottomData.leaderboards}
class={data.hideCmsZonesOnMobile ? 'hidden lg:contents' : ''}
/>
{/if}
diff --git a/src/routes/(app)/product/[id]/+layout.svelte b/src/routes/(app)/product/[id]/+layout.svelte
index 130e2a7b4..dd14c0f6b 100644
--- a/src/routes/(app)/product/[id]/+layout.svelte
+++ b/src/routes/(app)/product/[id]/+layout.svelte
@@ -21,6 +21,7 @@
sessionEmail={data.email}
countdowns={data.cmsData.countdowns}
galleries={data.cmsData.galleries}
+ leaderboards={data.cmsData.leaderboards}
/>{:else}
{/if}
diff --git a/src/routes/(app)/product/[id]/+page.svelte b/src/routes/(app)/product/[id]/+page.svelte
index 1cdb9e130..e9dee06ff 100644
--- a/src/routes/(app)/product/[id]/+page.svelte
+++ b/src/routes/(app)/product/[id]/+page.svelte
@@ -178,6 +178,7 @@
sessionEmail={data.email}
countdowns={data.productCMSBefore.countdowns}
galleries={data.productCMSBefore.galleries}
+ leaderboards={data.productCMSBefore.leaderboards}
class={data.product.mobile?.hideContentBefore || data.hideCmsZonesOnMobile
? 'hidden lg:contents'
: ''}
@@ -577,6 +578,7 @@
sessionEmail={data.email}
countdowns={data.productCMSAfter.countdowns}
galleries={data.productCMSAfter.galleries}
+ leaderboards={data.productCMSAfter.leaderboards}
class={data.product.mobile?.hideContentAfter || data.hideCmsZonesOnMobile
? 'hidden lg:contents'
: ''}