new file: aethex-bot/migrations/add_whitelabel_branding.sql
This commit is contained in:
parent
e344e3d4cc
commit
d07ccd0f53
12 changed files with 3964 additions and 35 deletions
36
aethex-bot/.env.example
Normal file
36
aethex-bot/.env.example
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Discord Bot Configuration (REQUIRED)
|
||||
DISCORD_BOT_TOKEN=your_bot_token_here
|
||||
DISCORD_CLIENT_ID=your_client_id_here
|
||||
DISCORD_CLIENT_SECRET=your_client_secret_here
|
||||
|
||||
# Supabase Database (REQUIRED for full features)
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_SERVICE_ROLE=your_service_role_key
|
||||
|
||||
# Stripe Payments (for premium/branding tiers)
|
||||
STRIPE_SECRET_KEY=sk_live_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
STRIPE_PRICE_BRANDING_BASIC=price_xxx
|
||||
STRIPE_PRICE_BRANDING_PRO=price_xxx
|
||||
STRIPE_PRICE_BRANDING_ENTERPRISE=price_xxx
|
||||
|
||||
# OAuth & Web Dashboard
|
||||
BASE_URL=https://bot.aethex.dev
|
||||
SESSION_SECRET=random_secret_string
|
||||
|
||||
# Optional: AeThex Realm Guild IDs
|
||||
HUB_GUILD_ID=
|
||||
LABS_GUILD_ID=
|
||||
GAMEFORGE_GUILD_ID=
|
||||
CORP_GUILD_ID=
|
||||
FOUNDATION_GUILD_ID=
|
||||
|
||||
# Optional: Alert Channel
|
||||
ALERT_CHANNEL_ID=
|
||||
|
||||
# Optional: Admin Access
|
||||
WHITELISTED_USERS=user_id_1,user_id_2
|
||||
|
||||
# Server Ports
|
||||
PORT=8080
|
||||
HEALTH_PORT=3000
|
||||
479
aethex-bot/commands/branding.js
Normal file
479
aethex-bot/commands/branding.js
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } = require('discord.js');
|
||||
const { getBranding, updateBranding, claimHandle, hasFeature, BRANDING_TIERS, createBrandedEmbed } = require('../utils/brandingManager');
|
||||
|
||||
module.exports = {
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('branding')
|
||||
.setDescription('Configure white-label branding for your server')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('view')
|
||||
.setDescription('View current branding settings')
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('name')
|
||||
.setDescription('Set custom bot name (Basic tier+)')
|
||||
.addStringOption(opt => opt
|
||||
.setName('name')
|
||||
.setDescription('Custom bot name (max 80 chars)')
|
||||
.setRequired(true)
|
||||
.setMaxLength(80)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('avatar')
|
||||
.setDescription('Set custom bot avatar (Pro tier+)')
|
||||
.addStringOption(opt => opt
|
||||
.setName('url')
|
||||
.setDescription('Avatar image URL (must be https)')
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('footer')
|
||||
.setDescription('Set custom embed footer (Basic tier+)')
|
||||
.addStringOption(opt => opt
|
||||
.setName('text')
|
||||
.setDescription('Footer text (max 200 chars)')
|
||||
.setRequired(true)
|
||||
.setMaxLength(200)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('color')
|
||||
.setDescription('Set custom embed color (Basic tier+)')
|
||||
.addStringOption(opt => opt
|
||||
.setName('hex')
|
||||
.setDescription('Hex color (e.g., #ff5500)')
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('handle')
|
||||
.setDescription('Claim your custom URL handle (Pro tier+)')
|
||||
.addStringOption(opt => opt
|
||||
.setName('handle')
|
||||
.setDescription('Your custom handle (aethex.dev/YOUR-HANDLE)')
|
||||
.setRequired(true)
|
||||
.setMinLength(3)
|
||||
.setMaxLength(30)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('landing')
|
||||
.setDescription('Configure your landing page (Pro tier+)')
|
||||
.addStringOption(opt => opt
|
||||
.setName('title')
|
||||
.setDescription('Page title')
|
||||
.setMaxLength(100)
|
||||
)
|
||||
.addStringOption(opt => opt
|
||||
.setName('description')
|
||||
.setDescription('Page description')
|
||||
.setMaxLength(500)
|
||||
)
|
||||
.addStringOption(opt => opt
|
||||
.setName('banner')
|
||||
.setDescription('Banner image URL')
|
||||
)
|
||||
.addStringOption(opt => opt
|
||||
.setName('invite')
|
||||
.setDescription('Discord invite URL')
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('toggle')
|
||||
.setDescription('Enable or disable branding')
|
||||
.addBooleanOption(opt => opt
|
||||
.setName('enabled')
|
||||
.setDescription('Enable custom branding?')
|
||||
.setRequired(true)
|
||||
)
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('preview')
|
||||
.setDescription('Preview your branded embeds')
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('tiers')
|
||||
.setDescription('View branding tier pricing and features')
|
||||
)
|
||||
.addSubcommand(sub => sub
|
||||
.setName('reset')
|
||||
.setDescription('Reset branding to defaults')
|
||||
),
|
||||
|
||||
async execute(interaction, supabase) {
|
||||
if (!supabase) {
|
||||
return interaction.reply({ content: 'Database not available.', ephemeral: true });
|
||||
}
|
||||
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const guildId = interaction.guildId;
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
try {
|
||||
const branding = await getBranding(supabase, guildId);
|
||||
|
||||
// View current branding
|
||||
if (subcommand === 'view') {
|
||||
const tierInfo = BRANDING_TIERS[branding.tier || 'free'];
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(branding.custom_embed_color || '#6366f1')
|
||||
.setTitle('🏷️ White-Label Branding Settings')
|
||||
.setDescription(
|
||||
`**Current Tier:** ${tierInfo.name} (${tierInfo.price > 0 ? `$${tierInfo.price}/mo` : 'Free'})\n` +
|
||||
`**Status:** ${branding.branding_enabled ? '✅ Enabled' : '❌ Disabled'}`
|
||||
)
|
||||
.addFields(
|
||||
{ name: 'Custom Bot Name', value: branding.custom_bot_name || '*Not set*', inline: true },
|
||||
{ name: 'Custom Footer', value: branding.custom_footer_text || '*Not set*', inline: true },
|
||||
{ name: 'Embed Color', value: branding.custom_embed_color || '*Default*', inline: true },
|
||||
{ name: 'Custom Avatar', value: branding.custom_bot_avatar_url ? '✅ Set' : '*Not set*', inline: true },
|
||||
{ name: 'Custom Handle', value: branding.custom_handle ? `aethex.dev/${branding.custom_handle}` : '*Not claimed*', inline: true },
|
||||
{ name: 'Landing Page', value: branding.landing_title ? '✅ Configured' : '*Not set*', inline: true }
|
||||
)
|
||||
.setFooter({ text: 'Use /branding tiers to see upgrade options' })
|
||||
.setTimestamp();
|
||||
|
||||
if (branding.custom_bot_avatar_url) {
|
||||
embed.setThumbnail(branding.custom_bot_avatar_url);
|
||||
}
|
||||
|
||||
const row = new ActionRowBuilder()
|
||||
.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setLabel('Manage on Dashboard')
|
||||
.setURL(`https://bot.aethex.dev/dashboard?guild=${guildId}&page=branding`)
|
||||
.setStyle(ButtonStyle.Link),
|
||||
new ButtonBuilder()
|
||||
.setLabel('Upgrade Tier')
|
||||
.setURL('https://bot.aethex.dev/pricing#branding')
|
||||
.setStyle(ButtonStyle.Link)
|
||||
);
|
||||
|
||||
return interaction.editReply({ embeds: [embed], components: [row] });
|
||||
}
|
||||
|
||||
// View pricing tiers
|
||||
if (subcommand === 'tiers') {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor('#6366f1')
|
||||
.setTitle('🏷️ White-Label Branding Tiers')
|
||||
.setDescription('Customize Warden to match your community brand!')
|
||||
.addFields(
|
||||
{
|
||||
name: '🆓 Free',
|
||||
value: '• Default AeThex | Warden branding\n• All bot features included\n• No customization',
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '💎 Basic - $15/mo',
|
||||
value: '• Custom bot name\n• Custom footer text\n• Custom embed color\n• Removes "AeThex" branding',
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '⭐ Pro - $35/mo',
|
||||
value: '• Everything in Basic\n• Custom bot avatar\n• Custom URL handle\n• Landing page\n• `aethex.dev/your-name`',
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '🏆 Enterprise - $75/mo',
|
||||
value: '• Everything in Pro\n• Analytics dashboard\n• Priority support\n• Custom domain support\n• White-glove setup',
|
||||
inline: false
|
||||
}
|
||||
)
|
||||
.setFooter({ text: 'All tiers include full bot functionality • Payments via Stripe' });
|
||||
|
||||
const row = new ActionRowBuilder()
|
||||
.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setLabel('Subscribe Now')
|
||||
.setURL('https://bot.aethex.dev/pricing#branding')
|
||||
.setStyle(ButtonStyle.Link)
|
||||
.setEmoji('💳')
|
||||
);
|
||||
|
||||
return interaction.editReply({ embeds: [embed], components: [row] });
|
||||
}
|
||||
|
||||
// Set custom name
|
||||
if (subcommand === 'name') {
|
||||
if (!hasFeature(branding.tier, 'custom_name')) {
|
||||
return interaction.editReply({
|
||||
content: '❌ Custom bot names require **Basic tier** or higher. Use `/branding tiers` to see pricing.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
const name = interaction.options.getString('name');
|
||||
const result = await updateBranding(supabase, guildId, { custom_bot_name: name }, interaction.user.id);
|
||||
|
||||
if (result.success) {
|
||||
return interaction.editReply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor('#22c55e')
|
||||
.setTitle('✅ Bot Name Updated')
|
||||
.setDescription(`Bot will now appear as **${name}** in branded messages.`)
|
||||
.setFooter({ text: 'Make sure branding is enabled with /branding toggle' })
|
||||
]
|
||||
});
|
||||
}
|
||||
return interaction.editReply({ content: `❌ Failed: ${result.error}` });
|
||||
}
|
||||
|
||||
// Set custom avatar
|
||||
if (subcommand === 'avatar') {
|
||||
if (!hasFeature(branding.tier, 'custom_avatar')) {
|
||||
return interaction.editReply({
|
||||
content: '❌ Custom avatars require **Pro tier** or higher. Use `/branding tiers` to see pricing.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
const url = interaction.options.getString('url');
|
||||
|
||||
// Basic URL validation
|
||||
if (!url.startsWith('https://')) {
|
||||
return interaction.editReply({ content: '❌ Avatar URL must start with https://' });
|
||||
}
|
||||
|
||||
const result = await updateBranding(supabase, guildId, { custom_bot_avatar_url: url }, interaction.user.id);
|
||||
|
||||
if (result.success) {
|
||||
return interaction.editReply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor('#22c55e')
|
||||
.setTitle('✅ Bot Avatar Updated')
|
||||
.setDescription('Bot will now use your custom avatar in branded messages.')
|
||||
.setThumbnail(url)
|
||||
.setFooter({ text: 'Webhook-based messages will show custom avatar' })
|
||||
]
|
||||
});
|
||||
}
|
||||
return interaction.editReply({ content: `❌ Failed: ${result.error}` });
|
||||
}
|
||||
|
||||
// Set custom footer
|
||||
if (subcommand === 'footer') {
|
||||
if (!hasFeature(branding.tier, 'custom_footer')) {
|
||||
return interaction.editReply({
|
||||
content: '❌ Custom footers require **Basic tier** or higher. Use `/branding tiers` to see pricing.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
const text = interaction.options.getString('text');
|
||||
const result = await updateBranding(supabase, guildId, { custom_footer_text: text }, interaction.user.id);
|
||||
|
||||
if (result.success) {
|
||||
return interaction.editReply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor('#22c55e')
|
||||
.setTitle('✅ Footer Updated')
|
||||
.setDescription('Embeds will now show your custom footer.')
|
||||
.setFooter({ text: text })
|
||||
]
|
||||
});
|
||||
}
|
||||
return interaction.editReply({ content: `❌ Failed: ${result.error}` });
|
||||
}
|
||||
|
||||
// Set custom color
|
||||
if (subcommand === 'color') {
|
||||
if (!hasFeature(branding.tier, 'custom_color')) {
|
||||
return interaction.editReply({
|
||||
content: '❌ Custom colors require **Basic tier** or higher. Use `/branding tiers` to see pricing.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
let hex = interaction.options.getString('hex');
|
||||
|
||||
// Validate and normalize hex color
|
||||
if (!hex.startsWith('#')) hex = '#' + hex;
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) {
|
||||
return interaction.editReply({ content: '❌ Invalid hex color. Use format: #ff5500' });
|
||||
}
|
||||
|
||||
const result = await updateBranding(supabase, guildId, { custom_embed_color: hex }, interaction.user.id);
|
||||
|
||||
if (result.success) {
|
||||
return interaction.editReply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor(hex)
|
||||
.setTitle('✅ Embed Color Updated')
|
||||
.setDescription(`Embeds will now use **${hex}** as the accent color.`)
|
||||
]
|
||||
});
|
||||
}
|
||||
return interaction.editReply({ content: `❌ Failed: ${result.error}` });
|
||||
}
|
||||
|
||||
// Claim custom handle
|
||||
if (subcommand === 'handle') {
|
||||
const handle = interaction.options.getString('handle');
|
||||
const result = await claimHandle(supabase, guildId, handle, branding.tier);
|
||||
|
||||
if (result.success) {
|
||||
return interaction.editReply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor('#22c55e')
|
||||
.setTitle('✅ Handle Claimed!')
|
||||
.setDescription(`Your community page is now live at:\n\n🔗 **https://bot.aethex.dev/${result.handle}**`)
|
||||
.addFields(
|
||||
{ name: 'Configure Your Page', value: 'Use `/branding landing` to customize your page content.' }
|
||||
)
|
||||
.setFooter({ text: 'Share this link to promote your server!' })
|
||||
]
|
||||
});
|
||||
}
|
||||
return interaction.editReply({ content: `❌ ${result.error}` });
|
||||
}
|
||||
|
||||
// Configure landing page
|
||||
if (subcommand === 'landing') {
|
||||
if (!hasFeature(branding.tier, 'landing_page')) {
|
||||
return interaction.editReply({
|
||||
content: '❌ Landing pages require **Pro tier** or higher. Use `/branding tiers` to see pricing.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
if (!branding.custom_handle) {
|
||||
return interaction.editReply({
|
||||
content: '❌ You must claim a handle first with `/branding handle`',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
const title = interaction.options.getString('title');
|
||||
const description = interaction.options.getString('description');
|
||||
const banner = interaction.options.getString('banner');
|
||||
const invite = interaction.options.getString('invite');
|
||||
|
||||
if (title) updates.landing_title = title;
|
||||
if (description) updates.landing_description = description;
|
||||
if (banner) updates.landing_banner_url = banner;
|
||||
if (invite) updates.landing_invite_url = invite;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return interaction.editReply({
|
||||
content: 'Please provide at least one option to update.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
const result = await updateBranding(supabase, guildId, updates, interaction.user.id);
|
||||
|
||||
if (result.success) {
|
||||
return interaction.editReply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor('#22c55e')
|
||||
.setTitle('✅ Landing Page Updated')
|
||||
.setDescription(`View your page at:\n🔗 **https://bot.aethex.dev/${branding.custom_handle}**`)
|
||||
.addFields(
|
||||
Object.entries(updates).map(([key, value]) => ({
|
||||
name: key.replace('landing_', '').replace('_', ' '),
|
||||
value: value.length > 50 ? value.substring(0, 50) + '...' : value,
|
||||
inline: true
|
||||
}))
|
||||
)
|
||||
]
|
||||
});
|
||||
}
|
||||
return interaction.editReply({ content: `❌ Failed: ${result.error}` });
|
||||
}
|
||||
|
||||
// Toggle branding
|
||||
if (subcommand === 'toggle') {
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
|
||||
if (enabled && branding.tier === 'free') {
|
||||
return interaction.editReply({
|
||||
content: '❌ Custom branding requires a paid tier. Use `/branding tiers` to see pricing.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
const result = await updateBranding(supabase, guildId, { branding_enabled: enabled }, interaction.user.id);
|
||||
|
||||
if (result.success) {
|
||||
return interaction.editReply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor(enabled ? '#22c55e' : '#ef4444')
|
||||
.setTitle(enabled ? '✅ Branding Enabled' : '❌ Branding Disabled')
|
||||
.setDescription(
|
||||
enabled
|
||||
? 'Bot messages will now use your custom branding!'
|
||||
: 'Bot messages will show default AeThex | Warden branding.'
|
||||
)
|
||||
]
|
||||
});
|
||||
}
|
||||
return interaction.editReply({ content: `❌ Failed: ${result.error}` });
|
||||
}
|
||||
|
||||
// Preview branded embed
|
||||
if (subcommand === 'preview') {
|
||||
const previewEmbed = await createBrandedEmbed(supabase, guildId, {
|
||||
title: '🎉 Sample Announcement',
|
||||
description: 'This is how your branded embeds will look!\n\nNotice the custom color and footer below.',
|
||||
fields: [
|
||||
{ name: 'Feature', value: 'Custom branding', inline: true },
|
||||
{ name: 'Status', value: branding.branding_enabled ? '✅ Active' : '❌ Inactive', inline: true }
|
||||
],
|
||||
timestamp: true
|
||||
});
|
||||
|
||||
return interaction.editReply({
|
||||
content: branding.branding_enabled
|
||||
? '**Preview of your branded embed:**'
|
||||
: '**Preview (branding not enabled - showing defaults):**',
|
||||
embeds: [previewEmbed]
|
||||
});
|
||||
}
|
||||
|
||||
// Reset branding
|
||||
if (subcommand === 'reset') {
|
||||
const result = await updateBranding(supabase, guildId, {
|
||||
custom_bot_name: null,
|
||||
custom_bot_avatar_url: null,
|
||||
custom_footer_text: null,
|
||||
custom_embed_color: null,
|
||||
landing_title: null,
|
||||
landing_description: null,
|
||||
landing_banner_url: null,
|
||||
branding_enabled: false
|
||||
}, interaction.user.id);
|
||||
|
||||
if (result.success) {
|
||||
return interaction.editReply({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor('#f59e0b')
|
||||
.setTitle('🔄 Branding Reset')
|
||||
.setDescription('All custom branding has been cleared. Default AeThex | Warden branding restored.')
|
||||
.setFooter({ text: 'Your custom handle has been preserved' })
|
||||
]
|
||||
});
|
||||
}
|
||||
return interaction.editReply({ content: `❌ Failed: ${result.error}` });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Branding Command] Error:', error);
|
||||
return interaction.editReply({ content: '❌ An error occurred. Please try again.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
const { EmbedBuilder } = require('discord.js');
|
||||
const { getEffectiveFooter, getEffectiveColor } = require('../utils/brandingManager');
|
||||
|
||||
module.exports = {
|
||||
name: 'messageCreate',
|
||||
|
|
@ -130,11 +131,17 @@ async function handleViolation(message, violation, client, supabase) {
|
|||
spam: '🔄 Slow down! You\'re sending messages too fast.'
|
||||
};
|
||||
|
||||
// Get branded footer and color for embeds
|
||||
const brandedFooter = await getEffectiveFooter(supabase, message.guild.id);
|
||||
const brandedColorObj = await getEffectiveColor(supabase, message.guild.id);
|
||||
const embedColor = brandedColorObj.color || 0xef4444;
|
||||
const logEmbedColor = brandedColorObj.color || 0xf97316;
|
||||
|
||||
const dmEmbed = new EmbedBuilder()
|
||||
.setColor(0xef4444)
|
||||
.setColor(embedColor)
|
||||
.setTitle('⚠️ Message Removed')
|
||||
.setDescription(violationMessages[violation.type] || 'Your message violated server rules.')
|
||||
.setFooter({ text: message.guild.name })
|
||||
.setFooter({ text: brandedFooter })
|
||||
.setTimestamp();
|
||||
|
||||
await message.author.send({ embeds: [dmEmbed] }).catch(() => {});
|
||||
|
|
@ -164,7 +171,7 @@ async function handleViolation(message, violation, client, supabase) {
|
|||
const logChannel = await client.channels.fetch(config.modlog_channel).catch(() => null);
|
||||
if (logChannel) {
|
||||
const logEmbed = new EmbedBuilder()
|
||||
.setColor(0xf97316)
|
||||
.setColor(logEmbedColor)
|
||||
.setTitle('🛡️ Auto-Mod Action')
|
||||
.addFields(
|
||||
{ name: 'User', value: `${message.author.tag} (${message.author.id})`, inline: true },
|
||||
|
|
@ -172,7 +179,7 @@ async function handleViolation(message, violation, client, supabase) {
|
|||
{ name: 'Action', value: violation.action, inline: true },
|
||||
{ name: 'Channel', value: `<#${message.channel.id}>`, inline: true }
|
||||
)
|
||||
.setFooter({ text: 'Auto-Moderation' })
|
||||
.setFooter({ text: brandedFooter })
|
||||
.setTimestamp();
|
||||
|
||||
await logChannel.send({ embeds: [logEmbed] });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { EmbedBuilder } = require('discord.js');
|
||||
const { getEffectiveFooter, getEffectiveColor, sendBrandedMessage } = require('../utils/brandingManager');
|
||||
|
||||
module.exports = {
|
||||
name: 'guildMemberRemove',
|
||||
|
|
@ -35,8 +36,13 @@ module.exports = {
|
|||
];
|
||||
const randomMessage = goodbyeMessages[Math.floor(Math.random() * goodbyeMessages.length)];
|
||||
|
||||
// Get branded footer and color
|
||||
const brandedFooter = await getEffectiveFooter(supabase, member.guild.id);
|
||||
const brandedColorObj = await getEffectiveColor(supabase, member.guild.id);
|
||||
const embedColor = brandedColorObj.color || 0xef4444;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0xef4444)
|
||||
.setColor(embedColor)
|
||||
.setAuthor({
|
||||
name: 'Member Left',
|
||||
iconURL: member.guild.iconURL({ size: 64 })
|
||||
|
|
@ -61,7 +67,7 @@ module.exports = {
|
|||
}
|
||||
)
|
||||
.setFooter({
|
||||
text: `ID: ${member.id}`,
|
||||
text: brandedFooter,
|
||||
iconURL: member.displayAvatarURL({ size: 32 })
|
||||
})
|
||||
.setTimestamp();
|
||||
|
|
@ -74,7 +80,8 @@ module.exports = {
|
|||
});
|
||||
}
|
||||
|
||||
await channel.send({ embeds: [embed] });
|
||||
// Use branded message for Pro+ tiers with custom bot name/avatar
|
||||
await sendBrandedMessage(supabase, channel, { embeds: [embed] }, member.guild.id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Goodbye error:', error.message);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const { EmbedBuilder, AttachmentBuilder } = require('discord.js');
|
||||
const { generateWelcomeCard } = require('../utils/welcomeCard');
|
||||
const { getEffectiveFooter, getEffectiveColor, sendBrandedMessage } = require('../utils/brandingManager');
|
||||
|
||||
module.exports = {
|
||||
name: 'guildMemberAdd',
|
||||
|
|
@ -56,10 +57,16 @@ module.exports = {
|
|||
];
|
||||
const randomMessage = welcomeMessages[Math.floor(Math.random() * welcomeMessages.length)];
|
||||
|
||||
// Get branded footer and color
|
||||
const brandedFooter = await getEffectiveFooter(supabase, member.guild.id);
|
||||
const brandedColorObj = await getEffectiveColor(supabase, member.guild.id);
|
||||
const embedColor = brandedColorObj.color || parseInt((config.welcome_card_accent_color || '#7c3aed').replace('#', ''), 16);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(parseInt((config.welcome_card_accent_color || '#7c3aed').replace('#', ''), 16))
|
||||
.setColor(embedColor)
|
||||
.setDescription(randomMessage)
|
||||
.setImage('attachment://welcome.png');
|
||||
.setImage('attachment://welcome.png')
|
||||
.setFooter({ text: brandedFooter });
|
||||
|
||||
if (isNewAccount) {
|
||||
embed.addFields({
|
||||
|
|
@ -77,7 +84,8 @@ module.exports = {
|
|||
});
|
||||
}
|
||||
|
||||
await channel.send({ embeds: [embed], files: [attachment] });
|
||||
// Use branded message for Pro+ tiers with custom bot name/avatar
|
||||
await sendBrandedMessage(supabase, channel, { embeds: [embed], files: [attachment] }, member.guild.id);
|
||||
return;
|
||||
} catch (cardError) {
|
||||
console.error('Welcome card generation failed, falling back to text:', cardError.message);
|
||||
|
|
@ -93,8 +101,13 @@ module.exports = {
|
|||
];
|
||||
const randomMessage = welcomeMessages[Math.floor(Math.random() * welcomeMessages.length)];
|
||||
|
||||
// Get branded footer and color
|
||||
const brandedFooter = await getEffectiveFooter(supabase, member.guild.id);
|
||||
const brandedColorObj = await getEffectiveColor(supabase, member.guild.id);
|
||||
const embedColor = brandedColorObj.color || 0x22c55e;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x22c55e)
|
||||
.setColor(embedColor)
|
||||
.setAuthor({
|
||||
name: 'Welcome to the server!',
|
||||
iconURL: member.guild.iconURL({ size: 64 })
|
||||
|
|
@ -119,7 +132,7 @@ module.exports = {
|
|||
}
|
||||
)
|
||||
.setFooter({
|
||||
text: `ID: ${member.id}`,
|
||||
text: brandedFooter,
|
||||
iconURL: member.displayAvatarURL({ size: 32 })
|
||||
})
|
||||
.setTimestamp();
|
||||
|
|
@ -140,7 +153,8 @@ module.exports = {
|
|||
});
|
||||
}
|
||||
|
||||
await channel.send({ embeds: [embed] });
|
||||
// Use branded message for Pro+ tiers with custom bot name/avatar
|
||||
await sendBrandedMessage(supabase, channel, { embeds: [embed] }, member.guild.id);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const { checkAchievements } = require('../commands/achievements');
|
|||
const { getServerMode, getEmbedColor } = require('../utils/modeHelper');
|
||||
const { updateStandaloneXp, calculateLevel: standaloneCalcLevel } = require('../utils/standaloneXp');
|
||||
const { getActiveSeasonalEvents, getSeasonalMultiplier, getSeasonalBonusCoins } = require('../utils/seasonalEvents');
|
||||
const { getEffectiveFooter, getEffectiveColor, sendBrandedMessage } = require('../utils/brandingManager');
|
||||
|
||||
const xpCooldowns = new Map();
|
||||
const xpConfigCache = new Map();
|
||||
|
|
@ -223,7 +224,7 @@ module.exports = {
|
|||
stats.dailyStreak = fullProfile?.daily_streak || 0;
|
||||
|
||||
if (newLevel > oldLevel) {
|
||||
await sendLevelUpAnnouncement(message, newLevel, newXp, config, client);
|
||||
await sendLevelUpAnnouncement(message, newLevel, newXp, config, client, supabase);
|
||||
await checkMilestoneRoles(message.member, {
|
||||
level: newLevel,
|
||||
prestige: prestige,
|
||||
|
|
@ -255,7 +256,7 @@ module.exports = {
|
|||
}
|
||||
};
|
||||
|
||||
async function sendLevelUpAnnouncement(message, newLevel, newXp, config, client) {
|
||||
async function sendLevelUpAnnouncement(message, newLevel, newXp, config, client, supabase) {
|
||||
try {
|
||||
const messageTemplate = config.levelup_message || '🎉 Congratulations {user}! You reached **Level {level}**!';
|
||||
const channelId = config.levelup_channel_id;
|
||||
|
|
@ -270,13 +271,19 @@ async function sendLevelUpAnnouncement(message, newLevel, newXp, config, client)
|
|||
.replace(/{xp}/g, newXp.toLocaleString())
|
||||
.replace(/{server}/g, message.guild.name);
|
||||
|
||||
// Get branded footer and color
|
||||
const brandedFooter = await getEffectiveFooter(supabase, message.guild.id);
|
||||
const brandedColorObj = await getEffectiveColor(supabase, message.guild.id);
|
||||
const finalColor = brandedColorObj.color || parseInt(embedColor.replace('#', ''), 16);
|
||||
|
||||
let messageContent;
|
||||
|
||||
if (useEmbed) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setDescription(formattedMessage)
|
||||
.setColor(parseInt(embedColor.replace('#', ''), 16))
|
||||
.setColor(finalColor)
|
||||
.setThumbnail(message.author.displayAvatarURL({ dynamic: true }))
|
||||
.setFooter({ text: brandedFooter })
|
||||
.setTimestamp();
|
||||
|
||||
messageContent = { embeds: [embed] };
|
||||
|
|
@ -287,17 +294,17 @@ async function sendLevelUpAnnouncement(message, newLevel, newXp, config, client)
|
|||
if (sendDm) {
|
||||
const dmSent = await message.author.send(messageContent).catch(() => null);
|
||||
if (!dmSent) {
|
||||
await message.channel.send(messageContent).catch(() => {});
|
||||
await sendBrandedMessage(supabase, message.channel, messageContent, message.guild.id);
|
||||
}
|
||||
} else if (channelId) {
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (channel) {
|
||||
await channel.send(messageContent).catch(() => {});
|
||||
await sendBrandedMessage(supabase, channel, messageContent, message.guild.id);
|
||||
} else {
|
||||
await message.channel.send(messageContent).catch(() => {});
|
||||
await sendBrandedMessage(supabase, message.channel, messageContent, message.guild.id);
|
||||
}
|
||||
} else {
|
||||
await message.channel.send(messageContent).catch(() => {});
|
||||
await sendBrandedMessage(supabase, message.channel, messageContent, message.guild.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Level-up announcement error:', error.message);
|
||||
|
|
@ -643,7 +650,7 @@ async function handleStandaloneXp(message, client, supabase, config, discordUser
|
|||
|
||||
// Level up announcement for standalone
|
||||
if (newLevel > oldLevel) {
|
||||
await sendStandaloneLevelUp(message, newLevel, newXp, config, client);
|
||||
await sendStandaloneLevelUp(message, newLevel, newXp, config, client, supabase);
|
||||
await checkMilestoneRolesStandalone(message.member, {
|
||||
level: newLevel,
|
||||
prestige: prestige,
|
||||
|
|
@ -656,7 +663,7 @@ async function handleStandaloneXp(message, client, supabase, config, discordUser
|
|||
}
|
||||
}
|
||||
|
||||
async function sendStandaloneLevelUp(message, newLevel, newXp, config, client) {
|
||||
async function sendStandaloneLevelUp(message, newLevel, newXp, config, client, supabase) {
|
||||
try {
|
||||
const messageTemplate = config.levelup_message || '🎉 Congratulations {user}! You reached **Level {level}**!';
|
||||
const channelId = config.levelup_channel_id;
|
||||
|
|
@ -671,14 +678,20 @@ async function sendStandaloneLevelUp(message, newLevel, newXp, config, client) {
|
|||
.replace(/{xp}/g, newXp.toLocaleString())
|
||||
.replace(/{server}/g, message.guild.name);
|
||||
|
||||
// Get branded footer and color
|
||||
const brandedFooter = await getEffectiveFooter(supabase, message.guild.id);
|
||||
const brandedColorObj = await getEffectiveColor(supabase, message.guild.id);
|
||||
const finalColor = brandedColorObj.color || parseInt(embedColor.replace('#', ''), 16);
|
||||
const finalFooter = brandedFooter !== 'Powered by AeThex' ? brandedFooter : '🏠 Standalone Mode';
|
||||
|
||||
let messageContent;
|
||||
|
||||
if (useEmbed) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setDescription(formattedMessage)
|
||||
.setColor(parseInt(embedColor.replace('#', ''), 16))
|
||||
.setColor(finalColor)
|
||||
.setThumbnail(message.author.displayAvatarURL({ dynamic: true }))
|
||||
.setFooter({ text: '🏠 Standalone Mode' })
|
||||
.setFooter({ text: finalFooter })
|
||||
.setTimestamp();
|
||||
|
||||
messageContent = { embeds: [embed] };
|
||||
|
|
@ -689,17 +702,17 @@ async function sendStandaloneLevelUp(message, newLevel, newXp, config, client) {
|
|||
if (sendDm) {
|
||||
const dmSent = await message.author.send(messageContent).catch(() => null);
|
||||
if (!dmSent) {
|
||||
await message.channel.send(messageContent).catch(() => {});
|
||||
await sendBrandedMessage(supabase, message.channel, messageContent, message.guild.id);
|
||||
}
|
||||
} else if (channelId) {
|
||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
||||
if (channel) {
|
||||
await channel.send(messageContent).catch(() => {});
|
||||
await sendBrandedMessage(supabase, channel, messageContent, message.guild.id);
|
||||
} else {
|
||||
await message.channel.send(messageContent).catch(() => {});
|
||||
await sendBrandedMessage(supabase, message.channel, messageContent, message.guild.id);
|
||||
}
|
||||
} else {
|
||||
await message.channel.send(messageContent).catch(() => {});
|
||||
await sendBrandedMessage(supabase, message.channel, messageContent, message.guild.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Standalone level-up announcement error:', error.message);
|
||||
|
|
|
|||
102
aethex-bot/migrations/add_whitelabel_branding.sql
Normal file
102
aethex-bot/migrations/add_whitelabel_branding.sql
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
-- White-Label Branding System Migration
|
||||
-- Allows paying servers to customize bot identity and get custom handles
|
||||
|
||||
-- Server branding configuration
|
||||
CREATE TABLE IF NOT EXISTS server_branding (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guild_id VARCHAR(32) UNIQUE NOT NULL,
|
||||
|
||||
-- Custom Identity
|
||||
custom_bot_name VARCHAR(80),
|
||||
custom_bot_avatar_url VARCHAR(512),
|
||||
custom_footer_text VARCHAR(200),
|
||||
custom_embed_color VARCHAR(10),
|
||||
|
||||
-- Custom Handle (aethex.dev/{handle})
|
||||
custom_handle VARCHAR(50) UNIQUE,
|
||||
handle_verified BOOLEAN DEFAULT false,
|
||||
|
||||
-- Landing Page Content
|
||||
landing_title VARCHAR(100),
|
||||
landing_description TEXT,
|
||||
landing_banner_url VARCHAR(512),
|
||||
landing_invite_url VARCHAR(200),
|
||||
landing_website_url VARCHAR(200),
|
||||
landing_social_links JSONB DEFAULT '{}',
|
||||
landing_features JSONB DEFAULT '[]',
|
||||
landing_theme VARCHAR(20) DEFAULT 'default',
|
||||
|
||||
-- Branding Tier
|
||||
tier VARCHAR(20) DEFAULT 'free', -- free, basic, pro, enterprise
|
||||
branding_enabled BOOLEAN DEFAULT false,
|
||||
|
||||
-- Subscription
|
||||
subscription_id VARCHAR(100),
|
||||
subscription_status VARCHAR(20),
|
||||
subscription_started_at TIMESTAMP WITH TIME ZONE,
|
||||
subscription_expires_at TIMESTAMP WITH TIME ZONE,
|
||||
|
||||
-- Metadata
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
created_by VARCHAR(32)
|
||||
);
|
||||
|
||||
-- Indexes for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_branding_handle ON server_branding(custom_handle) WHERE custom_handle IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_branding_tier ON server_branding(tier);
|
||||
CREATE INDEX IF NOT EXISTS idx_branding_enabled ON server_branding(branding_enabled);
|
||||
|
||||
-- Branding analytics (track custom landing page visits)
|
||||
CREATE TABLE IF NOT EXISTS branding_analytics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guild_id VARCHAR(32) NOT NULL,
|
||||
event_type VARCHAR(50) NOT NULL, -- page_view, invite_click, social_click
|
||||
referrer VARCHAR(512),
|
||||
user_agent VARCHAR(512),
|
||||
ip_hash VARCHAR(64), -- hashed for privacy
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_branding_analytics_guild ON branding_analytics(guild_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_branding_analytics_date ON branding_analytics(created_at);
|
||||
|
||||
-- Reserved handles (prevent abuse)
|
||||
CREATE TABLE IF NOT EXISTS reserved_handles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
handle VARCHAR(50) UNIQUE NOT NULL,
|
||||
reason VARCHAR(200),
|
||||
reserved_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Insert reserved handles
|
||||
INSERT INTO reserved_handles (handle, reason) VALUES
|
||||
('admin', 'System reserved'),
|
||||
('api', 'System reserved'),
|
||||
('dashboard', 'System reserved'),
|
||||
('login', 'System reserved'),
|
||||
('auth', 'System reserved'),
|
||||
('oauth', 'System reserved'),
|
||||
('federation', 'System reserved'),
|
||||
('pricing', 'System reserved'),
|
||||
('features', 'System reserved'),
|
||||
('commands', 'System reserved'),
|
||||
('support', 'System reserved'),
|
||||
('help', 'System reserved'),
|
||||
('aethex', 'Brand reserved'),
|
||||
('warden', 'Brand reserved'),
|
||||
('official', 'Brand reserved'),
|
||||
('staff', 'Brand reserved'),
|
||||
('mod', 'Brand reserved'),
|
||||
('moderator', 'Brand reserved')
|
||||
ON CONFLICT (handle) DO NOTHING;
|
||||
|
||||
-- White-label pricing tiers reference:
|
||||
-- Basic ($15/mo): Custom bot name, footer, embed color
|
||||
-- Pro ($35/mo): + Custom avatar, custom handle, landing page
|
||||
-- Enterprise ($75/mo): + Analytics, priority support, custom domain support
|
||||
|
||||
COMMENT ON TABLE server_branding IS 'White-label branding configuration for servers';
|
||||
COMMENT ON COLUMN server_branding.custom_handle IS 'Custom URL handle at aethex.dev/{handle}';
|
||||
COMMENT ON COLUMN server_branding.tier IS 'Branding tier: free, basic, pro, enterprise';
|
||||
1463
aethex-bot/package-lock.json
generated
1463
aethex-bot/package-lock.json
generated
File diff suppressed because it is too large
Load diff
560
aethex-bot/public/community.html
Normal file
560
aethex-bot/public/community.html
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Community - AeThex</title>
|
||||
<meta name="description" content="Join our community on Discord">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<link rel="icon" href="/logo.png" type="image/png">
|
||||
<style>
|
||||
:root {
|
||||
--background: #030712;
|
||||
--foreground: #f8fafc;
|
||||
--card: rgba(15, 23, 42, 0.6);
|
||||
--card-border: rgba(99, 102, 241, 0.15);
|
||||
--card-border-hover: rgba(99, 102, 241, 0.4);
|
||||
--primary: #6366f1;
|
||||
--primary-light: #818cf8;
|
||||
--secondary: rgba(30, 41, 59, 0.5);
|
||||
--muted: #64748b;
|
||||
--border: rgba(51, 65, 85, 0.5);
|
||||
--success: #10b981;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
pointer-events: none;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
.bg-glow {
|
||||
position: fixed;
|
||||
top: -50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 150%;
|
||||
height: 100%;
|
||||
background: radial-gradient(ellipse at center, rgba(99, 102, 241, 0.15) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading .spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-page {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.error-page h1 {
|
||||
font-size: 6rem;
|
||||
color: var(--primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.error-page p {
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.community-page {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.banner {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, var(--primary), #3b82f6);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: -60px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.banner img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, transparent 50%, var(--background) 100%);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--background);
|
||||
background: var(--card);
|
||||
object-fit: cover;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.community-name {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.community-handle {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.member-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--secondary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.member-count .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--success);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 600px;
|
||||
margin: 1.5rem auto;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1.75rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #5865F2, #7289DA);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(88, 101, 242, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.features-section {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.features-title {
|
||||
font-size: 1.25rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--card-border-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.feature-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.socials-section {
|
||||
margin-top: 2.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
border-color: var(--primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.powered-by {
|
||||
margin-top: 4rem;
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.powered-by a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.powered-by a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.powered-by img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tier-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.tier-badge.pro {
|
||||
background: linear-gradient(135deg, #a855f7, #6366f1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tier-badge.enterprise {
|
||||
background: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.banner { height: 150px; }
|
||||
.avatar { width: 100px; height: 100px; }
|
||||
.community-name { font-size: 1.5rem; }
|
||||
.cta-buttons { flex-direction: column; width: 100%; }
|
||||
.btn { width: 100%; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-grid"></div>
|
||||
<div class="bg-glow"></div>
|
||||
|
||||
<div id="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading community...</p>
|
||||
</div>
|
||||
|
||||
<div id="error" class="error-page">
|
||||
<h1>404</h1>
|
||||
<h2>Community Not Found</h2>
|
||||
<p>This community page doesn't exist or has been removed.</p>
|
||||
<a href="/" class="btn btn-primary">Go Home</a>
|
||||
</div>
|
||||
|
||||
<div id="community" class="community-page">
|
||||
<div class="container">
|
||||
<div class="banner" id="banner">
|
||||
<img id="banner-img" src="" alt="Banner" style="display: none;">
|
||||
<div class="banner-overlay"></div>
|
||||
</div>
|
||||
|
||||
<div class="profile-section">
|
||||
<img id="avatar" class="avatar" src="/logo.png" alt="Community Avatar">
|
||||
<h1 class="community-name">
|
||||
<span id="name">Community</span>
|
||||
<span id="tier-badge" class="tier-badge" style="display: none;"></span>
|
||||
</h1>
|
||||
<p class="community-handle" id="handle">@handle</p>
|
||||
<div class="member-count" id="member-count" style="display: none;">
|
||||
<span class="dot"></span>
|
||||
<span id="members">0</span> members
|
||||
</div>
|
||||
<p class="description" id="description"></p>
|
||||
|
||||
<div class="cta-buttons">
|
||||
<a href="#" id="invite-btn" class="btn btn-primary" style="display: none;" onclick="trackEvent('invite_click')">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.09.09 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.09 16.09 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02zM8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12zm6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12z"/></svg>
|
||||
Join Discord
|
||||
</a>
|
||||
<a href="#" id="website-btn" class="btn btn-secondary" style="display: none;" onclick="trackEvent('website_click')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
Website
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="features-section" class="features-section" style="display: none;">
|
||||
<h3 class="features-title">What We Offer</h3>
|
||||
<div id="features-grid" class="features-grid"></div>
|
||||
</div>
|
||||
|
||||
<div id="socials-section" class="socials-section" style="display: none;"></div>
|
||||
|
||||
<div class="powered-by">
|
||||
<a href="/">
|
||||
<img src="/logo.png" alt="AeThex">
|
||||
Powered by AeThex | Warden
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const handle = window.location.pathname.split('/').filter(Boolean)[0];
|
||||
|
||||
async function loadCommunity() {
|
||||
try {
|
||||
const res = await fetch(`/api/community/${handle}`);
|
||||
|
||||
if (!res.ok) {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('error').style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Update page
|
||||
document.title = `${data.title || data.name} - AeThex`;
|
||||
document.querySelector('meta[name="description"]').content = data.description || '';
|
||||
|
||||
// Avatar
|
||||
if (data.avatar) {
|
||||
document.getElementById('avatar').src = data.avatar;
|
||||
}
|
||||
|
||||
// Banner
|
||||
if (data.banner) {
|
||||
const bannerImg = document.getElementById('banner-img');
|
||||
bannerImg.src = data.banner;
|
||||
bannerImg.style.display = 'block';
|
||||
}
|
||||
|
||||
// Name and handle
|
||||
document.getElementById('name').textContent = data.title || data.name;
|
||||
document.getElementById('handle').textContent = `aethex.dev/${data.handle}`;
|
||||
|
||||
// Tier badge
|
||||
if (data.tier === 'pro' || data.tier === 'enterprise') {
|
||||
const badge = document.getElementById('tier-badge');
|
||||
badge.textContent = data.tier === 'pro' ? '⭐ Pro' : '🏆 Enterprise';
|
||||
badge.className = `tier-badge ${data.tier}`;
|
||||
badge.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
// Member count
|
||||
if (data.memberCount) {
|
||||
document.getElementById('members').textContent = data.memberCount.toLocaleString();
|
||||
document.getElementById('member-count').style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
// Description
|
||||
document.getElementById('description').textContent = data.description || '';
|
||||
|
||||
// Invite button
|
||||
if (data.invite) {
|
||||
const inviteBtn = document.getElementById('invite-btn');
|
||||
inviteBtn.href = data.invite;
|
||||
inviteBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
// Website button
|
||||
if (data.website) {
|
||||
const websiteBtn = document.getElementById('website-btn');
|
||||
websiteBtn.href = data.website;
|
||||
websiteBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
// Features
|
||||
if (data.features && data.features.length > 0) {
|
||||
const featuresSection = document.getElementById('features-section');
|
||||
const featuresGrid = document.getElementById('features-grid');
|
||||
|
||||
data.features.forEach(feature => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'feature-card';
|
||||
card.innerHTML = `
|
||||
<div class="feature-icon">${feature.icon || '✨'}</div>
|
||||
<div class="feature-name">${feature.name}</div>
|
||||
`;
|
||||
featuresGrid.appendChild(card);
|
||||
});
|
||||
|
||||
featuresSection.style.display = 'block';
|
||||
}
|
||||
|
||||
// Social links
|
||||
if (data.socials && Object.keys(data.socials).length > 0) {
|
||||
const socialsSection = document.getElementById('socials-section');
|
||||
|
||||
const socialIcons = {
|
||||
twitter: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>',
|
||||
youtube: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
|
||||
tiktok: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>',
|
||||
instagram: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>',
|
||||
github: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>',
|
||||
twitch: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"/></svg>',
|
||||
roblox: '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M5.164 0L0 18.576 18.836 24 24 5.424zM15.097 15.458l-6.093-1.534 1.534-6.093 6.093 1.534z"/></svg>'
|
||||
};
|
||||
|
||||
Object.entries(data.socials).forEach(([platform, url]) => {
|
||||
if (url) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'social-link';
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.innerHTML = socialIcons[platform] || platform[0].toUpperCase();
|
||||
link.title = platform;
|
||||
link.onclick = () => trackEvent('social_click');
|
||||
socialsSection.appendChild(link);
|
||||
}
|
||||
});
|
||||
|
||||
if (socialsSection.children.length > 0) {
|
||||
socialsSection.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
// Show community page
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('community').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load community:', error);
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
document.getElementById('error').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function trackEvent(eventType) {
|
||||
fetch(`/api/community/${handle}/track`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ event: eventType })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
loadCommunity();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1270,6 +1270,9 @@
|
|||
<div class="nav-item" data-page="backups">
|
||||
<span class="nav-icon">💾</span> Backups
|
||||
</div>
|
||||
<div class="nav-item" data-page="branding">
|
||||
<span class="nav-icon">🎨</span> Branding
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
@ -2457,6 +2460,259 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-branding" class="page hidden">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">Server <span class="text-gradient">Branding</span></h1>
|
||||
<p class="page-subtitle">Customize your bot's appearance and create your community landing page</p>
|
||||
</div>
|
||||
|
||||
<div id="brandingStatus" class="card" style="margin-bottom:1.5rem">
|
||||
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<h3 class="card-title">Your Plan</h3>
|
||||
<span id="brandingTierBadge" class="badge">Free</span>
|
||||
</div>
|
||||
<div class="card-body" id="brandingStatusContent">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Current Tier</div>
|
||||
<div class="stat-value" id="brandingCurrentTier">Free</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Status</div>
|
||||
<div class="stat-value" id="brandingSubStatus">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Expires</div>
|
||||
<div class="stat-value" id="brandingExpires">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:1rem;display:flex;gap:1rem;flex-wrap:wrap">
|
||||
<a href="/pricing.html#branding" target="_blank" class="btn btn-primary">View Plans</a>
|
||||
<button class="btn btn-secondary" onclick="refreshBranding()">Refresh Status</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs" style="margin-bottom:1.5rem">
|
||||
<div class="tab active" data-branding-tab="branding-identity">Identity</div>
|
||||
<div class="tab" data-branding-tab="branding-handle">Handle</div>
|
||||
<div class="tab" data-branding-tab="branding-landing">Landing Page</div>
|
||||
<div class="tab" data-branding-tab="branding-preview">Preview</div>
|
||||
</div>
|
||||
|
||||
<div id="branding-identity" class="branding-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Bot Identity</h3>
|
||||
<span class="badge" style="font-size:0.7rem">Basic+</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Custom Bot Name</label>
|
||||
<input type="text" class="form-input" id="brandingBotName" placeholder="Your Community Bot" maxlength="80">
|
||||
<small style="color:var(--muted)">This name appears in bot messages (Pro+ uses webhooks)</small>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Custom Footer Text</label>
|
||||
<input type="text" class="form-input" id="brandingFooter" placeholder="Powered by Your Brand" maxlength="200">
|
||||
<small style="color:var(--muted)">Replaces "Powered by AeThex" in embeds</small>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Embed Color</label>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center">
|
||||
<input type="color" id="brandingColorPicker" value="#6366f1" style="width:50px;height:40px;border:none;cursor:pointer">
|
||||
<input type="text" class="form-input" id="brandingColor" placeholder="#6366f1" maxlength="10" style="width:120px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Custom Avatar URL <span class="badge" style="font-size:0.6rem">Pro+</span></label>
|
||||
<input type="text" class="form-input" id="brandingAvatar" placeholder="https://your-domain.com/avatar.png">
|
||||
<small style="color:var(--muted)">Used with webhook messages for custom bot avatar</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="display:flex;align-items:center;gap:0.5rem">
|
||||
<input type="checkbox" id="brandingEnabled" style="width:auto">
|
||||
Enable Branding
|
||||
</label>
|
||||
<small style="color:var(--muted)">Toggle custom branding on/off without losing settings</small>
|
||||
</div>
|
||||
<button class="btn btn-primary" style="margin-top:1rem" onclick="saveBrandingIdentity()">Save Identity Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="branding-handle" class="branding-section hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Custom Handle</h3>
|
||||
<span class="badge" style="font-size:0.7rem">Pro+</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Your Handle</label>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center">
|
||||
<span style="color:var(--muted)">aethex.dev/</span>
|
||||
<input type="text" class="form-input" id="brandingHandle" placeholder="your-community" maxlength="50" style="flex:1">
|
||||
</div>
|
||||
<small style="color:var(--muted)">Alphanumeric and hyphens only, 3-50 characters</small>
|
||||
</div>
|
||||
<div id="handleStatus" style="margin-bottom:1rem;padding:1rem;border-radius:8px;background:var(--secondary)">
|
||||
<p id="handleStatusText">Enter a handle to check availability</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="claimHandle()">Claim Handle</button>
|
||||
<button class="btn btn-secondary" onclick="checkHandleAvailability()" style="margin-left:0.5rem">Check Availability</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:1.5rem">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Handle Benefits</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul style="list-style:none;padding:0">
|
||||
<li style="padding:0.5rem 0;border-bottom:1px solid var(--border)">✅ Custom URL: <code>aethex.dev/your-handle</code></li>
|
||||
<li style="padding:0.5rem 0;border-bottom:1px solid var(--border)">✅ Community landing page</li>
|
||||
<li style="padding:0.5rem 0;border-bottom:1px solid var(--border)">✅ SEO-friendly presence</li>
|
||||
<li style="padding:0.5rem 0">✅ Analytics (Enterprise)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="branding-landing" class="branding-section hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Landing Page Content</h3>
|
||||
<span class="badge" style="font-size:0.7rem">Pro+</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Page Title</label>
|
||||
<input type="text" class="form-input" id="landingTitle" placeholder="Welcome to Our Community" maxlength="100">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-input" id="landingDescription" placeholder="Join the best gaming community on Discord..." rows="4" style="resize:vertical"></textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Banner Image URL</label>
|
||||
<input type="text" class="form-input" id="landingBanner" placeholder="https://your-domain.com/banner.jpg">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Discord Invite URL</label>
|
||||
<input type="text" class="form-input" id="landingInvite" placeholder="https://discord.gg/your-invite">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Website URL (optional)</label>
|
||||
<input type="text" class="form-input" id="landingWebsite" placeholder="https://your-website.com">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Theme</label>
|
||||
<select class="form-select" id="landingTheme">
|
||||
<option value="default">Default</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="gaming">Gaming</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveLandingPage()">Save Landing Page</button>
|
||||
<a id="viewLandingBtn" href="#" target="_blank" class="btn btn-secondary" style="margin-left:0.5rem;display:none">View Page</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:1.5rem">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Social Links</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Twitter/X</label>
|
||||
<input type="text" class="form-input" id="socialTwitter" placeholder="https://twitter.com/yourhandle">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">YouTube</label>
|
||||
<input type="text" class="form-input" id="socialYoutube" placeholder="https://youtube.com/@yourchannel">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem">
|
||||
<label class="form-label">Twitch</label>
|
||||
<input type="text" class="form-input" id="socialTwitch" placeholder="https://twitch.tv/yourchannel">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveSocialLinks()">Save Social Links</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="branding-preview" class="branding-section hidden">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Embed Preview</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="embedPreview" style="background:#2f3136;border-radius:4px;padding:1rem;max-width:520px;border-left:4px solid #6366f1">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
|
||||
<img id="previewAvatar" src="/logo.png" style="width:40px;height:40px;border-radius:50%" alt="">
|
||||
<span id="previewBotName" style="font-weight:600;color:#fff">AeThex Bot</span>
|
||||
</div>
|
||||
<div style="color:#dcddde;margin:0.5rem 0">
|
||||
<strong>Welcome!</strong>
|
||||
<p style="margin:0.25rem 0">This is an example embed message from your server.</p>
|
||||
</div>
|
||||
<div id="previewFooter" style="color:#72767d;font-size:0.75rem;margin-top:0.5rem;padding-top:0.5rem;border-top:1px solid #40444b">
|
||||
Powered by AeThex
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="margin-top:1rem" onclick="updatePreview()">Refresh Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:1.5rem">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Pricing Tiers</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem">
|
||||
<div style="padding:1rem;background:var(--secondary);border-radius:8px;border:1px solid var(--border)">
|
||||
<h4 style="margin-bottom:0.5rem">Free</h4>
|
||||
<p style="font-size:1.5rem;font-weight:bold">$0</p>
|
||||
<ul style="font-size:0.85rem;color:var(--muted);padding-left:1rem;margin-top:0.5rem">
|
||||
<li>Default AeThex branding</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="padding:1rem;background:var(--secondary);border-radius:8px;border:1px solid var(--primary)">
|
||||
<h4 style="margin-bottom:0.5rem;color:var(--primary)">Basic</h4>
|
||||
<p style="font-size:1.5rem;font-weight:bold">$15<span style="font-size:0.8rem;color:var(--muted)">/mo</span></p>
|
||||
<ul style="font-size:0.85rem;color:var(--muted);padding-left:1rem;margin-top:0.5rem">
|
||||
<li>Custom bot name</li>
|
||||
<li>Custom footer</li>
|
||||
<li>Custom embed color</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="padding:1rem;background:var(--secondary);border-radius:8px;border:1px solid var(--success)">
|
||||
<h4 style="margin-bottom:0.5rem;color:var(--success)">Pro</h4>
|
||||
<p style="font-size:1.5rem;font-weight:bold">$35<span style="font-size:0.8rem;color:var(--muted)">/mo</span></p>
|
||||
<ul style="font-size:0.85rem;color:var(--muted);padding-left:1rem;margin-top:0.5rem">
|
||||
<li>Everything in Basic</li>
|
||||
<li>Custom avatar</li>
|
||||
<li>Custom handle</li>
|
||||
<li>Landing page</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="padding:1rem;background:var(--secondary);border-radius:8px;border:1px solid var(--warning)">
|
||||
<h4 style="margin-bottom:0.5rem;color:var(--warning)">Enterprise</h4>
|
||||
<p style="font-size:1.5rem;font-weight:bold">$75<span style="font-size:0.8rem;color:var(--muted)">/mo</span></p>
|
||||
<ul style="font-size:0.85rem;color:var(--muted);padding-left:1rem;margin-top:0.5rem">
|
||||
<li>Everything in Pro</li>
|
||||
<li>Analytics dashboard</li>
|
||||
<li>Priority support</li>
|
||||
<li>Custom domain</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
@ -2642,6 +2898,7 @@
|
|||
case 'activity-roles': loadActivityRoles(); break;
|
||||
case 'cooldowns': loadCooldowns(); break;
|
||||
case 'backups': loadBackups(); break;
|
||||
case 'branding': loadBranding(); break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4728,6 +4985,289 @@
|
|||
});
|
||||
});
|
||||
|
||||
// Branding tab navigation
|
||||
document.querySelectorAll('[data-branding-tab]').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-branding-tab]').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.querySelectorAll('.branding-section').forEach(s => s.classList.add('hidden'));
|
||||
document.getElementById(tab.dataset.brandingTab).classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// Branding color picker sync
|
||||
document.getElementById('brandingColorPicker')?.addEventListener('input', (e) => {
|
||||
document.getElementById('brandingColor').value = e.target.value;
|
||||
});
|
||||
document.getElementById('brandingColor')?.addEventListener('input', (e) => {
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) {
|
||||
document.getElementById('brandingColorPicker').value = e.target.value;
|
||||
}
|
||||
});
|
||||
|
||||
let currentBranding = null;
|
||||
|
||||
async function loadBranding() {
|
||||
if (!currentGuild) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/guild/' + currentGuild + '/branding');
|
||||
if (res.ok) {
|
||||
currentBranding = await res.json();
|
||||
populateBrandingForm(currentBranding);
|
||||
} else {
|
||||
currentBranding = null;
|
||||
resetBrandingForm();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load branding:', e);
|
||||
resetBrandingForm();
|
||||
}
|
||||
}
|
||||
|
||||
function populateBrandingForm(data) {
|
||||
// Tier status
|
||||
const tier = data.tier || 'free';
|
||||
const tierBadge = document.getElementById('brandingTierBadge');
|
||||
tierBadge.textContent = tier.charAt(0).toUpperCase() + tier.slice(1);
|
||||
tierBadge.className = 'badge';
|
||||
if (tier === 'pro') tierBadge.style.background = 'var(--success)';
|
||||
else if (tier === 'enterprise') tierBadge.style.background = 'var(--warning)';
|
||||
else if (tier === 'basic') tierBadge.style.background = 'var(--primary)';
|
||||
|
||||
document.getElementById('brandingCurrentTier').textContent = tier.charAt(0).toUpperCase() + tier.slice(1);
|
||||
document.getElementById('brandingSubStatus').textContent = data.subscription_status || 'Not subscribed';
|
||||
document.getElementById('brandingExpires').textContent = data.subscription_expires_at
|
||||
? new Date(data.subscription_expires_at).toLocaleDateString()
|
||||
: '-';
|
||||
|
||||
// Identity
|
||||
document.getElementById('brandingBotName').value = data.custom_bot_name || '';
|
||||
document.getElementById('brandingFooter').value = data.custom_footer_text || '';
|
||||
document.getElementById('brandingColor').value = data.custom_embed_color || '#6366f1';
|
||||
document.getElementById('brandingColorPicker').value = data.custom_embed_color || '#6366f1';
|
||||
document.getElementById('brandingAvatar').value = data.custom_bot_avatar_url || '';
|
||||
document.getElementById('brandingEnabled').checked = data.branding_enabled || false;
|
||||
|
||||
// Handle
|
||||
document.getElementById('brandingHandle').value = data.custom_handle || '';
|
||||
if (data.custom_handle) {
|
||||
document.getElementById('handleStatusText').textContent = '✅ Handle claimed: aethex.dev/' + data.custom_handle;
|
||||
document.getElementById('handleStatus').style.background = 'rgba(16,185,129,0.2)';
|
||||
document.getElementById('viewLandingBtn').href = '/' + data.custom_handle;
|
||||
document.getElementById('viewLandingBtn').style.display = 'inline-block';
|
||||
} else {
|
||||
document.getElementById('viewLandingBtn').style.display = 'none';
|
||||
}
|
||||
|
||||
// Landing page
|
||||
document.getElementById('landingTitle').value = data.landing_title || '';
|
||||
document.getElementById('landingDescription').value = data.landing_description || '';
|
||||
document.getElementById('landingBanner').value = data.landing_banner_url || '';
|
||||
document.getElementById('landingInvite').value = data.landing_invite_url || '';
|
||||
document.getElementById('landingWebsite').value = data.landing_website_url || '';
|
||||
document.getElementById('landingTheme').value = data.landing_theme || 'default';
|
||||
|
||||
// Social links
|
||||
const socials = data.landing_social_links || {};
|
||||
document.getElementById('socialTwitter').value = socials.twitter || '';
|
||||
document.getElementById('socialYoutube').value = socials.youtube || '';
|
||||
document.getElementById('socialTwitch').value = socials.twitch || '';
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function resetBrandingForm() {
|
||||
document.getElementById('brandingTierBadge').textContent = 'Free';
|
||||
document.getElementById('brandingCurrentTier').textContent = 'Free';
|
||||
document.getElementById('brandingSubStatus').textContent = 'Not subscribed';
|
||||
document.getElementById('brandingExpires').textContent = '-';
|
||||
|
||||
document.getElementById('brandingBotName').value = '';
|
||||
document.getElementById('brandingFooter').value = '';
|
||||
document.getElementById('brandingColor').value = '#6366f1';
|
||||
document.getElementById('brandingColorPicker').value = '#6366f1';
|
||||
document.getElementById('brandingAvatar').value = '';
|
||||
document.getElementById('brandingEnabled').checked = false;
|
||||
|
||||
document.getElementById('brandingHandle').value = '';
|
||||
document.getElementById('handleStatusText').textContent = 'Enter a handle to check availability';
|
||||
document.getElementById('handleStatus').style.background = 'var(--secondary)';
|
||||
document.getElementById('viewLandingBtn').style.display = 'none';
|
||||
|
||||
document.getElementById('landingTitle').value = '';
|
||||
document.getElementById('landingDescription').value = '';
|
||||
document.getElementById('landingBanner').value = '';
|
||||
document.getElementById('landingInvite').value = '';
|
||||
document.getElementById('landingWebsite').value = '';
|
||||
document.getElementById('landingTheme').value = 'default';
|
||||
|
||||
document.getElementById('socialTwitter').value = '';
|
||||
document.getElementById('socialYoutube').value = '';
|
||||
document.getElementById('socialTwitch').value = '';
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
async function refreshBranding() {
|
||||
await loadBranding();
|
||||
alert('Branding status refreshed');
|
||||
}
|
||||
|
||||
async function saveBrandingIdentity() {
|
||||
if (!currentGuild) return alert('Please select a server first');
|
||||
|
||||
const data = {
|
||||
custom_bot_name: document.getElementById('brandingBotName').value || null,
|
||||
custom_footer_text: document.getElementById('brandingFooter').value || null,
|
||||
custom_embed_color: document.getElementById('brandingColor').value || null,
|
||||
custom_bot_avatar_url: document.getElementById('brandingAvatar').value || null,
|
||||
branding_enabled: document.getElementById('brandingEnabled').checked
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/guild/' + currentGuild + '/branding', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('Branding identity saved!');
|
||||
await loadBranding();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to save branding');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to save branding');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHandleAvailability() {
|
||||
const handle = document.getElementById('brandingHandle').value.trim().toLowerCase();
|
||||
if (!handle) return alert('Please enter a handle');
|
||||
|
||||
if (!/^[a-z0-9-]{3,50}$/.test(handle)) {
|
||||
document.getElementById('handleStatusText').textContent = '❌ Invalid format. Use 3-50 alphanumeric characters or hyphens.';
|
||||
document.getElementById('handleStatus').style.background = 'rgba(239,68,68,0.2)';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/community/' + handle);
|
||||
if (res.status === 404) {
|
||||
document.getElementById('handleStatusText').textContent = '✅ Handle is available!';
|
||||
document.getElementById('handleStatus').style.background = 'rgba(16,185,129,0.2)';
|
||||
} else {
|
||||
document.getElementById('handleStatusText').textContent = '❌ Handle is already taken';
|
||||
document.getElementById('handleStatus').style.background = 'rgba(239,68,68,0.2)';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('handleStatusText').textContent = 'Error checking availability';
|
||||
}
|
||||
}
|
||||
|
||||
async function claimHandle() {
|
||||
if (!currentGuild) return alert('Please select a server first');
|
||||
|
||||
const handle = document.getElementById('brandingHandle').value.trim().toLowerCase();
|
||||
if (!handle) return alert('Please enter a handle');
|
||||
|
||||
if (!/^[a-z0-9-]{3,50}$/.test(handle)) {
|
||||
return alert('Invalid handle format. Use 3-50 alphanumeric characters or hyphens.');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/guild/' + currentGuild + '/branding', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ custom_handle: handle })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('Handle claimed successfully!');
|
||||
await loadBranding();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to claim handle');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to claim handle');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLandingPage() {
|
||||
if (!currentGuild) return alert('Please select a server first');
|
||||
|
||||
const data = {
|
||||
landing_title: document.getElementById('landingTitle').value || null,
|
||||
landing_description: document.getElementById('landingDescription').value || null,
|
||||
landing_banner_url: document.getElementById('landingBanner').value || null,
|
||||
landing_invite_url: document.getElementById('landingInvite').value || null,
|
||||
landing_website_url: document.getElementById('landingWebsite').value || null,
|
||||
landing_theme: document.getElementById('landingTheme').value
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/guild/' + currentGuild + '/branding', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('Landing page saved!');
|
||||
await loadBranding();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to save landing page');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to save landing page');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSocialLinks() {
|
||||
if (!currentGuild) return alert('Please select a server first');
|
||||
|
||||
const socials = {
|
||||
twitter: document.getElementById('socialTwitter').value || null,
|
||||
youtube: document.getElementById('socialYoutube').value || null,
|
||||
twitch: document.getElementById('socialTwitch').value || null
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/guild/' + currentGuild + '/branding', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ landing_social_links: socials })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('Social links saved!');
|
||||
await loadBranding();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to save social links');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Failed to save social links');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const name = document.getElementById('brandingBotName').value || 'AeThex Bot';
|
||||
const footer = document.getElementById('brandingFooter').value || 'Powered by AeThex';
|
||||
const color = document.getElementById('brandingColor').value || '#6366f1';
|
||||
const avatar = document.getElementById('brandingAvatar').value || '/logo.png';
|
||||
|
||||
document.getElementById('previewBotName').textContent = name;
|
||||
document.getElementById('previewFooter').textContent = footer;
|
||||
document.getElementById('previewAvatar').src = avatar;
|
||||
document.getElementById('embedPreview').style.borderLeftColor = color;
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,21 @@ function createWebServer(discordClient, supabase, options = {}) {
|
|||
}, { onConflict: 'guild_id' });
|
||||
|
||||
console.log(`[Stripe] Guild ${guild_id} purchased featured slot`);
|
||||
} else if (plan_type?.startsWith('branding_')) {
|
||||
// White-label branding subscription
|
||||
const brandingTier = plan_type.replace('branding_', '');
|
||||
|
||||
await supabase.from('server_branding').upsert({
|
||||
guild_id: guild_id,
|
||||
tier: brandingTier,
|
||||
subscription_id: session.subscription,
|
||||
subscription_status: 'active',
|
||||
subscription_started_at: new Date().toISOString(),
|
||||
branding_enabled: true,
|
||||
updated_at: new Date().toISOString()
|
||||
}, { onConflict: 'guild_id' });
|
||||
|
||||
console.log(`[Stripe] Guild ${guild_id} subscribed to branding tier: ${brandingTier}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -82,10 +97,19 @@ function createWebServer(discordClient, supabase, options = {}) {
|
|||
await supabase.from('federation_servers').update({
|
||||
subscription_status: 'active'
|
||||
}).eq('subscription_id', subscription.id);
|
||||
|
||||
// Also update branding subscriptions
|
||||
await supabase.from('server_branding').update({
|
||||
subscription_status: 'active'
|
||||
}).eq('subscription_id', subscription.id);
|
||||
} else if (status === 'past_due' || status === 'unpaid') {
|
||||
await supabase.from('federation_servers').update({
|
||||
subscription_status: status
|
||||
}).eq('subscription_id', subscription.id);
|
||||
|
||||
await supabase.from('server_branding').update({
|
||||
subscription_status: status
|
||||
}).eq('subscription_id', subscription.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -104,6 +128,15 @@ function createWebServer(discordClient, supabase, options = {}) {
|
|||
active: false
|
||||
}).eq('subscription_id', subscription.id);
|
||||
|
||||
// Cancel branding subscription - downgrade to free
|
||||
await supabase.from('server_branding').update({
|
||||
tier: 'free',
|
||||
subscription_id: null,
|
||||
subscription_status: null,
|
||||
branding_enabled: false,
|
||||
updated_at: new Date().toISOString()
|
||||
}).eq('subscription_id', subscription.id);
|
||||
|
||||
console.log(`[Stripe] Subscription ${subscription.id} canceled`);
|
||||
break;
|
||||
}
|
||||
|
|
@ -3127,6 +3160,287 @@ function createWebServer(discordClient, supabase, options = {}) {
|
|||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// White-Label Branding API Routes
|
||||
// ============================================
|
||||
const brandingManager = require('../utils/brandingManager');
|
||||
|
||||
// Get branding config for a guild
|
||||
app.get('/api/guild/:guildId/branding', async (req, res) => {
|
||||
if (!supabase) {
|
||||
return res.status(503).json({ error: 'Database not available' });
|
||||
}
|
||||
|
||||
const userId = req.session.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { guildId } = req.params;
|
||||
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
||||
if (!userGuild || !userGuild.isAdmin) {
|
||||
return res.status(403).json({ error: 'No admin access to this server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const branding = await brandingManager.getBranding(supabase, guildId);
|
||||
res.json({ branding, tiers: brandingManager.BRANDING_TIERS });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch branding' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update branding config
|
||||
app.post('/api/guild/:guildId/branding', async (req, res) => {
|
||||
if (!supabase) {
|
||||
return res.status(503).json({ error: 'Database not available' });
|
||||
}
|
||||
|
||||
const userId = req.session.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { guildId } = req.params;
|
||||
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
||||
if (!userGuild || !userGuild.isAdmin) {
|
||||
return res.status(403).json({ error: 'No admin access to this server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await brandingManager.updateBranding(supabase, guildId, req.body, userId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update branding' });
|
||||
}
|
||||
});
|
||||
|
||||
// Claim custom handle
|
||||
app.post('/api/guild/:guildId/branding/handle', async (req, res) => {
|
||||
if (!supabase) {
|
||||
return res.status(503).json({ error: 'Database not available' });
|
||||
}
|
||||
|
||||
const userId = req.session.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { guildId } = req.params;
|
||||
const { handle } = req.body;
|
||||
|
||||
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
||||
if (!userGuild || !userGuild.isAdmin) {
|
||||
return res.status(403).json({ error: 'No admin access to this server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const branding = await brandingManager.getBranding(supabase, guildId);
|
||||
const result = await brandingManager.claimHandle(supabase, guildId, handle, branding.tier);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to claim handle' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get branding analytics (Enterprise tier)
|
||||
app.get('/api/guild/:guildId/branding/analytics', async (req, res) => {
|
||||
if (!supabase) {
|
||||
return res.status(503).json({ error: 'Database not available' });
|
||||
}
|
||||
|
||||
const userId = req.session.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { guildId } = req.params;
|
||||
const { days = 30 } = req.query;
|
||||
|
||||
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
||||
if (!userGuild || !userGuild.isAdmin) {
|
||||
return res.status(403).json({ error: 'No admin access to this server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const branding = await brandingManager.getBranding(supabase, guildId);
|
||||
|
||||
if (!brandingManager.hasFeature(branding.tier, 'analytics')) {
|
||||
return res.status(403).json({ error: 'Analytics requires Enterprise tier' });
|
||||
}
|
||||
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - parseInt(days));
|
||||
|
||||
const { data: analytics } = await supabase
|
||||
.from('branding_analytics')
|
||||
.select('*')
|
||||
.eq('guild_id', guildId)
|
||||
.gte('created_at', since.toISOString())
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// Aggregate stats
|
||||
const pageViews = analytics?.filter(a => a.event_type === 'page_view').length || 0;
|
||||
const inviteClicks = analytics?.filter(a => a.event_type === 'invite_click').length || 0;
|
||||
const socialClicks = analytics?.filter(a => a.event_type === 'social_click').length || 0;
|
||||
|
||||
res.json({
|
||||
summary: { pageViews, inviteClicks, socialClicks, period: `${days} days` },
|
||||
events: analytics || []
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: Community landing page API
|
||||
app.get('/api/community/:handle', async (req, res) => {
|
||||
if (!supabase) {
|
||||
return res.status(503).json({ error: 'Database not available' });
|
||||
}
|
||||
|
||||
const { handle } = req.params;
|
||||
|
||||
try {
|
||||
const { data: branding } = await supabase
|
||||
.from('server_branding')
|
||||
.select('*')
|
||||
.eq('custom_handle', handle.toLowerCase())
|
||||
.eq('handle_verified', true)
|
||||
.maybeSingle();
|
||||
|
||||
if (!branding) {
|
||||
return res.status(404).json({ error: 'Community not found' });
|
||||
}
|
||||
|
||||
// Get server info from federation_servers or Discord
|
||||
let serverInfo = null;
|
||||
const { data: fedServer } = await supabase
|
||||
.from('federation_servers')
|
||||
.select('guild_name, guild_icon, member_count, description')
|
||||
.eq('guild_id', branding.guild_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (fedServer) {
|
||||
serverInfo = fedServer;
|
||||
} else {
|
||||
// Try to get from Discord cache
|
||||
const guild = discordClient.guilds.cache.get(branding.guild_id);
|
||||
if (guild) {
|
||||
serverInfo = {
|
||||
guild_name: guild.name,
|
||||
guild_icon: guild.iconURL({ size: 256 }),
|
||||
member_count: guild.memberCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Track page view
|
||||
await brandingManager.trackAnalytics(supabase, branding.guild_id, 'page_view', {
|
||||
referrer: req.headers.referer,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
|
||||
res.json({
|
||||
handle: branding.custom_handle,
|
||||
name: branding.custom_bot_name || serverInfo?.guild_name || 'Community',
|
||||
title: branding.landing_title || serverInfo?.guild_name,
|
||||
description: branding.landing_description || serverInfo?.description || 'Welcome to our community!',
|
||||
banner: branding.landing_banner_url,
|
||||
avatar: branding.custom_bot_avatar_url || serverInfo?.guild_icon,
|
||||
invite: branding.landing_invite_url,
|
||||
website: branding.landing_website_url,
|
||||
socials: branding.landing_social_links || {},
|
||||
features: branding.landing_features || [],
|
||||
theme: branding.landing_theme || 'default',
|
||||
memberCount: serverInfo?.member_count,
|
||||
tier: branding.tier
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Community] Error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch community' });
|
||||
}
|
||||
});
|
||||
|
||||
// Track community analytics events
|
||||
app.post('/api/community/:handle/track', async (req, res) => {
|
||||
if (!supabase) {
|
||||
return res.status(503).json({ error: 'Database not available' });
|
||||
}
|
||||
|
||||
const { handle } = req.params;
|
||||
const { event } = req.body;
|
||||
|
||||
try {
|
||||
const { data: branding } = await supabase
|
||||
.from('server_branding')
|
||||
.select('guild_id')
|
||||
.eq('custom_handle', handle.toLowerCase())
|
||||
.maybeSingle();
|
||||
|
||||
if (branding) {
|
||||
await brandingManager.trackAnalytics(supabase, branding.guild_id, event, {
|
||||
referrer: req.headers.referer,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.json({ success: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Branding subscription checkout (Stripe)
|
||||
app.post('/api/branding/checkout', async (req, res) => {
|
||||
if (!stripe) {
|
||||
return res.status(503).json({ error: 'Payments not configured' });
|
||||
}
|
||||
|
||||
const userId = req.session.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { guildId, tier } = req.body;
|
||||
|
||||
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
||||
if (!userGuild || !userGuild.isAdmin) {
|
||||
return res.status(403).json({ error: 'No admin access to this server' });
|
||||
}
|
||||
|
||||
const prices = {
|
||||
basic: process.env.STRIPE_PRICE_BRANDING_BASIC,
|
||||
pro: process.env.STRIPE_PRICE_BRANDING_PRO,
|
||||
enterprise: process.env.STRIPE_PRICE_BRANDING_ENTERPRISE
|
||||
};
|
||||
|
||||
const priceId = prices[tier];
|
||||
if (!priceId) {
|
||||
return res.status(400).json({ error: 'Invalid tier' });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${BASE_URL}/dashboard?guild=${guildId}&page=branding&success=true`,
|
||||
cancel_url: `${BASE_URL}/dashboard?guild=${guildId}&page=branding&canceled=true`,
|
||||
metadata: {
|
||||
guild_id: guildId,
|
||||
user_id: userId,
|
||||
plan_type: `branding_${tier}`
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error('[Branding Checkout] Error:', error);
|
||||
res.status(500).json({ error: 'Failed to create checkout session' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// NEXUS Security API Integration Routes
|
||||
// ============================================
|
||||
|
|
@ -3281,9 +3595,38 @@ function createWebServer(discordClient, supabase, options = {}) {
|
|||
}
|
||||
});
|
||||
|
||||
// Catch-all route for SPA - use middleware instead of wildcard
|
||||
app.use((req, res, next) => {
|
||||
// Catch-all route for SPA and custom community handles
|
||||
const reservedPaths = ['api', 'dashboard', 'federation', 'pricing', 'features', 'commands', 'auth', 'oauth', 'health', 'leaderboard'];
|
||||
|
||||
app.use(async (req, res, next) => {
|
||||
if (req.method === 'GET' && !req.path.startsWith('/api/')) {
|
||||
const pathParts = req.path.split('/').filter(Boolean);
|
||||
|
||||
// Check if this might be a custom community handle
|
||||
if (pathParts.length === 1 && !reservedPaths.includes(pathParts[0].toLowerCase())) {
|
||||
const potentialHandle = pathParts[0].toLowerCase();
|
||||
|
||||
// Check if handle exists in database
|
||||
if (supabase) {
|
||||
try {
|
||||
const { data: branding } = await supabase
|
||||
.from('server_branding')
|
||||
.select('custom_handle')
|
||||
.eq('custom_handle', potentialHandle)
|
||||
.eq('handle_verified', true)
|
||||
.maybeSingle();
|
||||
|
||||
if (branding) {
|
||||
// Serve community landing page
|
||||
return res.sendFile(path.join(__dirname, '../public/community.html'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Community Route] DB error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: serve index.html
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
} else if (req.path.startsWith('/api/')) {
|
||||
res.status(404).json({ error: 'API endpoint not found' });
|
||||
|
|
|
|||
375
aethex-bot/utils/brandingManager.js
Normal file
375
aethex-bot/utils/brandingManager.js
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
/**
|
||||
* White-Label Branding System
|
||||
* Allows paying servers to customize bot identity
|
||||
*/
|
||||
|
||||
const { WebhookClient, EmbedBuilder } = require('discord.js');
|
||||
|
||||
// Branding tier definitions
|
||||
const BRANDING_TIERS = {
|
||||
free: {
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
features: []
|
||||
},
|
||||
basic: {
|
||||
name: 'Basic',
|
||||
price: 15,
|
||||
features: ['custom_name', 'custom_footer', 'custom_color']
|
||||
},
|
||||
pro: {
|
||||
name: 'Pro',
|
||||
price: 35,
|
||||
features: ['custom_name', 'custom_footer', 'custom_color', 'custom_avatar', 'custom_handle', 'landing_page']
|
||||
},
|
||||
enterprise: {
|
||||
name: 'Enterprise',
|
||||
price: 75,
|
||||
features: ['custom_name', 'custom_footer', 'custom_color', 'custom_avatar', 'custom_handle', 'landing_page', 'analytics', 'priority_support', 'custom_domain']
|
||||
}
|
||||
};
|
||||
|
||||
// Reserved handles that can't be claimed
|
||||
const SYSTEM_HANDLES = [
|
||||
'admin', 'api', 'dashboard', 'login', 'auth', 'oauth', 'federation',
|
||||
'pricing', 'features', 'commands', 'support', 'help', 'aethex', 'warden',
|
||||
'official', 'staff', 'mod', 'moderator', 'bot', 'system', 'root'
|
||||
];
|
||||
|
||||
// Cache for branding data (reduces DB calls)
|
||||
const brandingCache = new Map();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get branding configuration for a guild
|
||||
*/
|
||||
async function getBranding(supabase, guildId) {
|
||||
// Check cache first
|
||||
const cached = brandingCache.get(guildId);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('server_branding')
|
||||
.select('*')
|
||||
.eq('guild_id', guildId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const result = data || getDefaultBranding();
|
||||
|
||||
// Cache the result
|
||||
brandingCache.set(guildId, { data: result, timestamp: Date.now() });
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[Branding] Error fetching branding:', err);
|
||||
return getDefaultBranding();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default branding (AeThex Warden)
|
||||
*/
|
||||
function getDefaultBranding() {
|
||||
return {
|
||||
custom_bot_name: null,
|
||||
custom_bot_avatar_url: null,
|
||||
custom_footer_text: null,
|
||||
custom_embed_color: null,
|
||||
custom_handle: null,
|
||||
tier: 'free',
|
||||
branding_enabled: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is available for a tier
|
||||
*/
|
||||
function hasFeature(tier, feature) {
|
||||
const tierConfig = BRANDING_TIERS[tier];
|
||||
return tierConfig?.features?.includes(feature) || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective bot name for a guild
|
||||
*/
|
||||
async function getEffectiveBotName(supabase, guildId, defaultName = 'AeThex | Warden') {
|
||||
const branding = await getBranding(supabase, guildId);
|
||||
|
||||
if (branding.branding_enabled && branding.custom_bot_name && hasFeature(branding.tier, 'custom_name')) {
|
||||
return branding.custom_bot_name;
|
||||
}
|
||||
|
||||
return defaultName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective avatar URL for a guild
|
||||
*/
|
||||
async function getEffectiveAvatar(supabase, guildId, defaultAvatar) {
|
||||
const branding = await getBranding(supabase, guildId);
|
||||
|
||||
if (branding.branding_enabled && branding.custom_bot_avatar_url && hasFeature(branding.tier, 'custom_avatar')) {
|
||||
return branding.custom_bot_avatar_url;
|
||||
}
|
||||
|
||||
return defaultAvatar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective embed color for a guild
|
||||
*/
|
||||
async function getEffectiveColor(supabase, guildId, defaultColor = '#6366f1') {
|
||||
const branding = await getBranding(supabase, guildId);
|
||||
|
||||
if (branding.branding_enabled && branding.custom_embed_color && hasFeature(branding.tier, 'custom_color')) {
|
||||
return branding.custom_embed_color;
|
||||
}
|
||||
|
||||
return defaultColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective footer text for a guild
|
||||
*/
|
||||
async function getEffectiveFooter(supabase, guildId, defaultFooter = 'AeThex | Warden') {
|
||||
const branding = await getBranding(supabase, guildId);
|
||||
|
||||
if (branding.branding_enabled && branding.custom_footer_text && hasFeature(branding.tier, 'custom_footer')) {
|
||||
return branding.custom_footer_text;
|
||||
}
|
||||
|
||||
return defaultFooter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branded embed for a guild
|
||||
*/
|
||||
async function createBrandedEmbed(supabase, guildId, options = {}) {
|
||||
const branding = await getBranding(supabase, guildId);
|
||||
|
||||
const embed = new EmbedBuilder();
|
||||
|
||||
// Set color
|
||||
const color = branding.branding_enabled && branding.custom_embed_color && hasFeature(branding.tier, 'custom_color')
|
||||
? branding.custom_embed_color
|
||||
: options.defaultColor || '#6366f1';
|
||||
embed.setColor(color);
|
||||
|
||||
// Set footer
|
||||
const footerText = branding.branding_enabled && branding.custom_footer_text && hasFeature(branding.tier, 'custom_footer')
|
||||
? branding.custom_footer_text
|
||||
: options.defaultFooter || 'AeThex | Warden';
|
||||
embed.setFooter({ text: footerText });
|
||||
|
||||
// Apply other options
|
||||
if (options.title) embed.setTitle(options.title);
|
||||
if (options.description) embed.setDescription(options.description);
|
||||
if (options.fields) embed.addFields(options.fields);
|
||||
if (options.thumbnail) embed.setThumbnail(options.thumbnail);
|
||||
if (options.image) embed.setImage(options.image);
|
||||
if (options.timestamp) embed.setTimestamp();
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a branded message via webhook (for full white-label experience)
|
||||
*/
|
||||
async function sendBrandedMessage(channel, supabase, guildId, options = {}) {
|
||||
const branding = await getBranding(supabase, guildId);
|
||||
|
||||
// If branding not enabled or no custom avatar, send normally
|
||||
if (!branding.branding_enabled || !hasFeature(branding.tier, 'custom_avatar')) {
|
||||
return channel.send(options);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get or create webhook for branding
|
||||
const webhooks = await channel.fetchWebhooks();
|
||||
let webhook = webhooks.find(wh => wh.name === 'AeThex-Branding');
|
||||
|
||||
if (!webhook) {
|
||||
webhook = await channel.createWebhook({
|
||||
name: 'AeThex-Branding',
|
||||
reason: 'White-label branding system'
|
||||
});
|
||||
}
|
||||
|
||||
// Send via webhook with custom identity
|
||||
const webhookClient = new WebhookClient({ url: webhook.url });
|
||||
|
||||
return await webhookClient.send({
|
||||
username: branding.custom_bot_name || 'AeThex | Warden',
|
||||
avatarURL: branding.custom_bot_avatar_url || undefined,
|
||||
content: options.content,
|
||||
embeds: options.embeds,
|
||||
files: options.files,
|
||||
components: options.components
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Branding] Webhook send failed, falling back to normal:', err.message);
|
||||
return channel.send(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update branding configuration
|
||||
*/
|
||||
async function updateBranding(supabase, guildId, updates, updatedBy) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('server_branding')
|
||||
.upsert({
|
||||
guild_id: guildId,
|
||||
...updates,
|
||||
updated_at: new Date().toISOString(),
|
||||
created_by: updatedBy
|
||||
}, { onConflict: 'guild_id' });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Invalidate cache
|
||||
brandingCache.delete(guildId);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('[Branding] Update error:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a custom handle
|
||||
*/
|
||||
async function claimHandle(supabase, guildId, handle, tier) {
|
||||
// Validate handle format
|
||||
const cleanHandle = handle.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
|
||||
if (cleanHandle.length < 3 || cleanHandle.length > 30) {
|
||||
return { success: false, error: 'Handle must be 3-30 characters (letters, numbers, hyphens only)' };
|
||||
}
|
||||
|
||||
// Check if system reserved
|
||||
if (SYSTEM_HANDLES.includes(cleanHandle)) {
|
||||
return { success: false, error: 'This handle is reserved' };
|
||||
}
|
||||
|
||||
// Check tier permission
|
||||
if (!hasFeature(tier, 'custom_handle')) {
|
||||
return { success: false, error: 'Custom handles require Pro tier or higher' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if reserved in database
|
||||
const { data: reserved } = await supabase
|
||||
.from('reserved_handles')
|
||||
.select('handle')
|
||||
.eq('handle', cleanHandle)
|
||||
.maybeSingle();
|
||||
|
||||
if (reserved) {
|
||||
return { success: false, error: 'This handle is reserved' };
|
||||
}
|
||||
|
||||
// Check if already taken
|
||||
const { data: existing } = await supabase
|
||||
.from('server_branding')
|
||||
.select('guild_id')
|
||||
.eq('custom_handle', cleanHandle)
|
||||
.neq('guild_id', guildId)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
return { success: false, error: 'This handle is already taken' };
|
||||
}
|
||||
|
||||
// Claim it
|
||||
const { error } = await supabase
|
||||
.from('server_branding')
|
||||
.upsert({
|
||||
guild_id: guildId,
|
||||
custom_handle: cleanHandle,
|
||||
handle_verified: true,
|
||||
updated_at: new Date().toISOString()
|
||||
}, { onConflict: 'guild_id' });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Invalidate cache
|
||||
brandingCache.delete(guildId);
|
||||
|
||||
return { success: true, handle: cleanHandle };
|
||||
} catch (err) {
|
||||
console.error('[Branding] Claim handle error:', err);
|
||||
return { success: false, error: 'Failed to claim handle' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get branding by custom handle
|
||||
*/
|
||||
async function getBrandingByHandle(supabase, handle) {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('server_branding')
|
||||
.select('*, federation_servers!inner(guild_name, guild_icon, member_count, description)')
|
||||
.eq('custom_handle', handle.toLowerCase())
|
||||
.eq('handle_verified', true)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('[Branding] Get by handle error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track landing page analytics
|
||||
*/
|
||||
async function trackAnalytics(supabase, guildId, eventType, metadata = {}) {
|
||||
try {
|
||||
await supabase.from('branding_analytics').insert({
|
||||
guild_id: guildId,
|
||||
event_type: eventType,
|
||||
referrer: metadata.referrer || null,
|
||||
user_agent: metadata.userAgent || null,
|
||||
ip_hash: metadata.ipHash || null,
|
||||
metadata: metadata.extra || {}
|
||||
});
|
||||
} catch (err) {
|
||||
// Silent fail for analytics
|
||||
console.error('[Branding] Analytics error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate branding cache for a guild
|
||||
*/
|
||||
function invalidateCache(guildId) {
|
||||
brandingCache.delete(guildId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BRANDING_TIERS,
|
||||
getBranding,
|
||||
getDefaultBranding,
|
||||
hasFeature,
|
||||
getEffectiveBotName,
|
||||
getEffectiveAvatar,
|
||||
getEffectiveColor,
|
||||
getEffectiveFooter,
|
||||
createBrandedEmbed,
|
||||
sendBrandedMessage,
|
||||
updateBranding,
|
||||
claimHandle,
|
||||
getBrandingByHandle,
|
||||
trackAnalytics,
|
||||
invalidateCache
|
||||
};
|
||||
Loading…
Reference in a new issue