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} +
+
+ + + +
+ + {productById[progress.productId].name} + + +
+
+
+ +{/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

+ +
+ + + + + + +

Progress

+ + {#each data.leaderboard.progress as progress, i} +

{progress.productId}

+
+ + + + {#if data.leaderboard.mode === 'moneyAmount'} + + {/if} +
+ {/each} + + + + + + + + +
+ + View + + +
+
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

+ +
+ + + + + {#if mode === 'moneyAmount'} + + {/if} +
+ +
+
+ +
+ + + + + +
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' : ''}