From bcac2584ed4010d9d6bd9a4b24a8010304bd09ed Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Mon, 26 May 2025 17:20:53 +0300 Subject: [PATCH] feat: promo code --- website/drizzle/0007_funny_hemingway.sql | 39 + website/drizzle/meta/0007_snapshot.json | 1078 +++++++++++++++++ website/drizzle/meta/_journal.json | 7 + .../src/lib/components/self/AppSidebar.svelte | 100 +- .../lib/components/self/DailyRewards.svelte | 15 +- .../components/self/PromoCodeDialog.svelte | 132 ++ website/src/lib/server/db/schema.ts | 24 +- website/src/lib/types/promo-code.ts | 20 + website/src/lib/utils.ts | 30 + website/src/routes/admin/promo/+page.svelte | 318 +++++ website/src/routes/api/admin/promo/+server.ts | 87 ++ .../src/routes/api/promo/verify/+server.ts | 112 ++ 12 files changed, 1908 insertions(+), 54 deletions(-) create mode 100644 website/drizzle/0007_funny_hemingway.sql create mode 100644 website/drizzle/meta/0007_snapshot.json create mode 100644 website/src/lib/components/self/PromoCodeDialog.svelte create mode 100644 website/src/lib/types/promo-code.ts create mode 100644 website/src/routes/admin/promo/+page.svelte create mode 100644 website/src/routes/api/admin/promo/+server.ts create mode 100644 website/src/routes/api/promo/verify/+server.ts diff --git a/website/drizzle/0007_funny_hemingway.sql b/website/drizzle/0007_funny_hemingway.sql new file mode 100644 index 0000000..961c3be --- /dev/null +++ b/website/drizzle/0007_funny_hemingway.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS "promo_code" ( + "id" serial PRIMARY KEY NOT NULL, + "code" varchar(50) NOT NULL, + "description" text, + "reward_amount" numeric(20, 8) NOT NULL, + "max_uses" integer, + "is_active" boolean DEFAULT true NOT NULL, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_by" integer, + CONSTRAINT "promo_code_code_unique" UNIQUE("code") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "promo_code_redemption" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "promo_code_id" integer NOT NULL, + "reward_amount" numeric(20, 8) NOT NULL, + "redeemed_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_code_redemption_user_id_promo_code_id_unique" UNIQUE("user_id","promo_code_id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "promo_code" ADD CONSTRAINT "promo_code_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "promo_code_redemption" ADD CONSTRAINT "promo_code_redemption_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "promo_code_redemption" ADD CONSTRAINT "promo_code_redemption_promo_code_id_promo_code_id_fk" FOREIGN KEY ("promo_code_id") REFERENCES "public"."promo_code"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/website/drizzle/meta/0007_snapshot.json b/website/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..9ca75b6 --- /dev/null +++ b/website/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1078 @@ +{ + "id": "b4f2dee8-8e04-4d31-9126-db23a5a97a98", + "prevId": "1de8a094-65a4-41ad-ad06-038f801c9fc6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.coin": { + "name": "coin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "initial_supply": { + "name": "initial_supply", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "circulating_supply": { + "name": "circulating_supply", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "current_price": { + "name": "current_price", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "market_cap": { + "name": "market_cap", + "type": "numeric(30, 2)", + "primaryKey": false, + "notNull": true + }, + "volume_24h": { + "name": "volume_24h", + "type": "numeric(30, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.00'" + }, + "change_24h": { + "name": "change_24h", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.0000'" + }, + "pool_coin_amount": { + "name": "pool_coin_amount", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "pool_base_currency_amount": { + "name": "pool_base_currency_amount", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_listed": { + "name": "is_listed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "coin_creator_id_user_id_fk": { + "name": "coin_creator_id_user_id_fk", + "tableFrom": "coin", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "coin_symbol_unique": { + "name": "coin_symbol_unique", + "nullsNotDistinct": false, + "columns": [ + "symbol" + ] + } + } + }, + "public.comment": { + "name": "comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "comment_user_id_idx": { + "name": "comment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_coin_id_idx": { + "name": "comment_coin_id_idx", + "columns": [ + { + "expression": "coin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_user_id_user_id_fk": { + "name": "comment_user_id_user_id_fk", + "tableFrom": "comment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_coin_id_coin_id_fk": { + "name": "comment_coin_id_coin_id_fk", + "tableFrom": "comment", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.comment_like": { + "name": "comment_like", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_like_user_id_user_id_fk": { + "name": "comment_like_user_id_user_id_fk", + "tableFrom": "comment_like", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_like_comment_id_comment_id_fk": { + "name": "comment_like_comment_id_comment_id_fk", + "tableFrom": "comment_like", + "tableTo": "comment", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_like_user_id_comment_id_pk": { + "name": "comment_like_user_id_comment_id_pk", + "columns": [ + "user_id", + "comment_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.price_history": { + "name": "price_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "price_history_coin_id_coin_id_fk": { + "name": "price_history_coin_id_coin_id_fk", + "tableFrom": "price_history", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.promo_code": { + "name": "promo_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_amount": { + "name": "reward_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "promo_code_created_by_user_id_fk": { + "name": "promo_code_created_by_user_id_fk", + "tableFrom": "promo_code", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_code_code_unique": { + "name": "promo_code_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + } + }, + "public.promo_code_redemption": { + "name": "promo_code_redemption", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "promo_code_id": { + "name": "promo_code_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reward_amount": { + "name": "reward_amount", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promo_code_redemption_user_id_user_id_fk": { + "name": "promo_code_redemption_user_id_user_id_fk", + "tableFrom": "promo_code_redemption", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "promo_code_redemption_promo_code_id_promo_code_id_fk": { + "name": "promo_code_redemption_promo_code_id_promo_code_id_fk", + "tableFrom": "promo_code_redemption", + "tableTo": "promo_code", + "columnsFrom": [ + "promo_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_code_redemption_user_id_promo_code_id_unique": { + "name": "promo_code_redemption_user_id_promo_code_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "promo_code_id" + ] + } + } + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "price_per_coin": { + "name": "price_per_coin", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "total_base_currency_amount": { + "name": "total_base_currency_amount", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "transaction_user_id_user_id_fk": { + "name": "transaction_user_id_user_id_fk", + "tableFrom": "transaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_coin_id_coin_id_fk": { + "name": "transaction_coin_id_coin_id_fk", + "tableFrom": "transaction", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_banned": { + "name": "is_banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_currency_balance": { + "name": "base_currency_balance", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'10000.00000000'" + }, + "bio": { + "name": "bio", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false, + "default": "'Hello am 48 year old man from somalia. Sorry for my bed england. I selled my wife for internet connection for play “conter stirk”'" + }, + "username": { + "name": "username", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "last_reward_claim": { + "name": "last_reward_claim", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_rewards_claimed": { + "name": "total_rewards_claimed", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0.00000000'" + }, + "login_streak": { + "name": "login_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "public.user_portfolio": { + "name": "user_portfolio", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "coin_id": { + "name": "coin_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(30, 8)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_portfolio_user_id_user_id_fk": { + "name": "user_portfolio_user_id_user_id_fk", + "tableFrom": "user_portfolio", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_portfolio_coin_id_coin_id_fk": { + "name": "user_portfolio_coin_id_coin_id_fk", + "tableFrom": "user_portfolio", + "tableTo": "coin", + "columnsFrom": [ + "coin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_portfolio_user_id_coin_id_pk": { + "name": "user_portfolio_user_id_coin_id_pk", + "columns": [ + "user_id", + "coin_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.transaction_type": { + "name": "transaction_type", + "schema": "public", + "values": [ + "BUY", + "SELL" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/website/drizzle/meta/_journal.json b/website/drizzle/meta/_journal.json index 865ec9b..57dde41 100644 --- a/website/drizzle/meta/_journal.json +++ b/website/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1748189449547, "tag": "0006_happy_katie_power", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1748262487765, + "tag": "0007_funny_hemingway", + "breakpoints": true } ] } \ No newline at end of file diff --git a/website/src/lib/components/self/AppSidebar.svelte b/website/src/lib/components/self/AppSidebar.svelte index e412e9a..e18cd0a 100644 --- a/website/src/lib/components/self/AppSidebar.svelte +++ b/website/src/lib/components/self/AppSidebar.svelte @@ -13,16 +13,17 @@ BriefcaseBusiness, Coins, ChevronsUpDownIcon, - SparklesIcon, - BadgeCheckIcon, - CreditCardIcon, - BellIcon, LogOutIcon, Wallet, Trophy, Activity, TrendingUp, - TrendingDown + TrendingDown, + User, + Settings, + Gift, + Shield, + Ticket } from 'lucide-svelte'; import { mode, setMode } from 'mode-watcher'; import type { HTMLAttributes } from 'svelte/elements'; @@ -31,6 +32,7 @@ import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import SignInConfirmDialog from './SignInConfirmDialog.svelte'; import DailyRewards from './DailyRewards.svelte'; + import PromoCodeDialog from './PromoCodeDialog.svelte'; import { signOut } from '$lib/auth-client'; import { formatValue, getPublicUrl } from '$lib/utils'; import { goto } from '$app/navigation'; @@ -43,13 +45,13 @@ { title: 'Portfolio', url: '/portfolio', icon: BriefcaseBusiness }, { title: 'Leaderboard', url: '/leaderboard', icon: Trophy }, { title: 'Create coin', url: '/coin/create', icon: Coins } - ], - navAdmin: [{ title: 'Admin', url: '/admin', icon: ShieldAlert }] + ] }; type MenuButtonProps = HTMLAttributes; const { setOpenMobile, isMobile } = useSidebar(); let shouldSignIn = $state(false); + let showPromoCode = $state(false); $effect(() => { if ($USER_DATA) { @@ -84,9 +86,32 @@ goto(`/coin/${coinSymbol.toLowerCase()}`); setOpenMobile(false); } + + function handleAccountClick() { + if ($USER_DATA) { + goto(`/user/${$USER_DATA.id}`); + setOpenMobile(false); + } + } + + function handleSettingsClick() { + goto('/settings'); + setOpenMobile(false); + } + + function handleAdminClick() { + goto('/admin'); + setOpenMobile(false); + } + + function handlePromoCodesClick() { + goto('/admin/promo'); + setOpenMobile(false); + } +
@@ -121,25 +146,6 @@ {/each} - {#if $USER_DATA?.isAdmin} - {#each data.navAdmin as item} - - - {#snippet child({ props }: { props: MenuButtonProps })} - handleNavClick(item.title)} - class={`${props.class}`} - > - - {item.title} - - {/snippet} - - - {/each} - {/if} - {#snippet child({ props }: { props: MenuButtonProps })} @@ -348,26 +354,38 @@ - - - Upgrade to Pro - - - - - goto('/settings')}> - + + Account - - - Billing + + + Settings - - - Notifications + (showPromoCode = true)}> + + Promo code + {#if $USER_DATA?.isAdmin} + + + + + Admin Panel + + + + Manage codes + + + {/if} { diff --git a/website/src/lib/components/self/DailyRewards.svelte b/website/src/lib/components/self/DailyRewards.svelte index a186d94..d88459e 100644 --- a/website/src/lib/components/self/DailyRewards.svelte +++ b/website/src/lib/components/self/DailyRewards.svelte @@ -1,10 +1,11 @@ + + { + if (!isOpen) resetDialog(); + }} +> + + + + + Promo Code + + + Enter your promo code below to redeem rewards and bonuses. + + + +
+
+ + +
+ + {#if hasResult} + + {#if isSuccess} + + {:else} + + {/if} + + {message} + + + {/if} + +
+ + +
+
+
+
diff --git a/website/src/lib/server/db/schema.ts b/website/src/lib/server/db/schema.ts index e84a806..25fc319 100644 --- a/website/src/lib/server/db/schema.ts +++ b/website/src/lib/server/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, text, timestamp, boolean, decimal, serial, varchar, integer, primaryKey, pgEnum, index } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, boolean, decimal, serial, varchar, integer, primaryKey, pgEnum, index, unique } from "drizzle-orm/pg-core"; export const transactionTypeEnum = pgEnum('transaction_type', ['BUY', 'SELL']); @@ -139,3 +139,25 @@ export const commentLike = pgTable("comment_like", { pk: primaryKey({ columns: [table.userId, table.commentId] }), }; }); + +export const promoCode = pgTable('promo_code', { + id: serial('id').primaryKey(), + code: varchar('code', { length: 50 }).notNull().unique(), + description: text('description'), + rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(), + maxUses: integer('max_uses'), // null = unlimited + isActive: boolean('is_active').notNull().default(true), + expiresAt: timestamp('expires_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + createdBy: integer('created_by').references(() => user.id), +}); + +export const promoCodeRedemption = pgTable('promo_code_redemption', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => user.id), + promoCodeId: integer('promo_code_id').notNull().references(() => promoCode.id), + rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(), + redeemedAt: timestamp('redeemed_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + userPromoUnique: unique().on(table.userId, table.promoCodeId), +})); diff --git a/website/src/lib/types/promo-code.ts b/website/src/lib/types/promo-code.ts new file mode 100644 index 0000000..d3e7079 --- /dev/null +++ b/website/src/lib/types/promo-code.ts @@ -0,0 +1,20 @@ +export interface PromoCode { + id: number; + code: string; + description?: string; + rewardAmount: string; + maxUses?: number; + isActive: boolean; + expiresAt?: string; + createdAt: string; + createdBy?: number; + usedCount?: number; +} + +export interface PromoCodeRedemption { + id: number; + userId: number; + promoCodeId: number; + rewardAmount: string; + redeemedAt: string; +} \ No newline at end of file diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts index 72b0011..5e4bd31 100644 --- a/website/src/lib/utils.ts +++ b/website/src/lib/utils.ts @@ -143,4 +143,34 @@ export function formatTimeAgo(date: string) { return `${diffDays}d ago`; } +export function formatTimeRemaining(timeMs: number): string { + const hours = Math.floor(timeMs / (60 * 60 * 1000)); + const minutes = Math.floor((timeMs % (60 * 60 * 1000)) / (60 * 1000)); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +export function getExpirationDate(option: string): string | null { + if (!option) return null; + + const now = new Date(); + switch (option) { + case '1h': + return new Date(now.getTime() + 60 * 60 * 1000).toISOString(); + case '1d': + return new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + case '3d': + return new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(); + case '7d': + return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(); + case '30d': + return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(); + default: + return null; + } +} + export const formatMarketCap = formatValue; diff --git a/website/src/routes/admin/promo/+page.svelte b/website/src/routes/admin/promo/+page.svelte new file mode 100644 index 0000000..d57e434 --- /dev/null +++ b/website/src/routes/admin/promo/+page.svelte @@ -0,0 +1,318 @@ + + + + Promo Codes - Admin | Rugplay + + +{#if !$USER_DATA || !$USER_DATA.isAdmin} +
+
+

Access Denied

+

You don't have permission to access this page.

+
+
+{:else} +
+
+ +

Promo Codes

+
+ +
+ + + + + + Create + + + Draft a new promo code for users to redeem. + + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + + + {currentExpirationLabel} + + + + {#each expirationOptions as option} + + {option.label} + + {/each} + + + +
+
+ + {#if hasCreateResult} + + {#if createSuccess} + + {:else} + + {/if} + + {createMessage} + {#if createSuccess && rewardAmount} + (+${rewardAmount} reward) + {/if} + + + {/if} + + +
+
+
+ + + + + Active + Manage existing promo codes. + + +
+ {#if isLoading} + {#each Array(3) as _} +
+
+ + +
+
+ + +
+
+ {/each} + {:else if promoCodes.length === 0} +
+ +

No codes created yet.

+
+ {:else} + {#each promoCodes as promo (promo.id)} +
+
+ + {promo.code} + + + {promo.isActive ? 'Active' : 'Inactive'} + +
+ +
+ ${promo.rewardAmount} +
+ + {promo.usedCount || 0}{promo.maxUses ? `/${promo.maxUses}` : ''} +
+
+ + {formatDate(promo.createdAt)} +
+ {#if promo.expiresAt} +
+ + Exp: {formatDate(promo.expiresAt)} +
+ {:else} +
+ + No expiry +
+ {/if} +
+
+ {/each} + {/if} +
+
+
+
+
+{/if} diff --git a/website/src/routes/api/admin/promo/+server.ts b/website/src/routes/api/admin/promo/+server.ts new file mode 100644 index 0000000..b4094b4 --- /dev/null +++ b/website/src/routes/api/admin/promo/+server.ts @@ -0,0 +1,87 @@ +import { auth } from '$lib/auth'; +import { error, json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { promoCode, promoCodeRedemption } from '$lib/server/db/schema'; +import { eq, count } from 'drizzle-orm'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request }) => { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user || !session.user.isAdmin) { + throw error(403, 'Admin access required'); + } + + const { code, description, rewardAmount, maxUses, expiresAt } = await request.json(); + + if (!code || !rewardAmount) { + return json({ error: 'Code and reward amount are required' }, { status: 400 }); + } + + const normalizedCode = code.trim().toUpperCase(); + const userId = Number(session.user.id); + + const [existingCode] = await db + .select({ id: promoCode.id }) + .from(promoCode) + .where(eq(promoCode.code, normalizedCode)) + .limit(1); + + if (existingCode) { + return json({ error: 'Promo code already exists' }, { status: 400 }); + } + + const [newPromoCode] = await db + .insert(promoCode) + .values({ + code: normalizedCode, + description: description || null, + rewardAmount: Number(rewardAmount).toFixed(8), + maxUses: maxUses || null, + expiresAt: expiresAt ? new Date(expiresAt) : null, + createdBy: userId + }) + .returning(); + + return json({ + success: true, + promoCode: { + id: newPromoCode.id, + code: newPromoCode.code, + description: newPromoCode.description, + rewardAmount: Number(newPromoCode.rewardAmount), + maxUses: newPromoCode.maxUses, + expiresAt: newPromoCode.expiresAt + } + }); + +}; + +export const GET: RequestHandler = async ({ request }) => { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user || !session.user.isAdmin) { + throw error(403, 'Admin access required'); + } + + const rows = await db + .select({ + id: promoCode.id, + code: promoCode.code, + description: promoCode.description, + rewardAmount: promoCode.rewardAmount, + maxUses: promoCode.maxUses, + isActive: promoCode.isActive, + createdAt: promoCode.createdAt, + expiresAt: promoCode.expiresAt, + usedCount: count(promoCodeRedemption.id).as('usedCount') + }) + .from(promoCode) + .leftJoin(promoCodeRedemption, eq(promoCode.id, promoCodeRedemption.promoCodeId)) + .groupBy(promoCode.id); + + return json({ + codes: rows.map(pc => ({ + ...pc, + rewardAmount: Number(pc.rewardAmount) + })) + }); +}; diff --git a/website/src/routes/api/promo/verify/+server.ts b/website/src/routes/api/promo/verify/+server.ts new file mode 100644 index 0000000..f15fb7d --- /dev/null +++ b/website/src/routes/api/promo/verify/+server.ts @@ -0,0 +1,112 @@ +import { auth } from '$lib/auth'; +import { error, json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { user, promoCode, promoCodeRedemption } from '$lib/server/db/schema'; +import { eq, and, count } from 'drizzle-orm'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request }) => { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user) { + throw error(401, 'Not authenticated'); + } + + const { code } = await request.json(); + + if (!code || typeof code !== 'string' || code.trim().length === 0) { + return json({ error: 'Promo code is required' }, { status: 400 }); + } + + const normalizedCode = code.trim().toUpperCase(); + const userId = Number(session.user.id); + + return await db.transaction(async (tx) => { + const [promoData] = await tx + .select({ + id: promoCode.id, + code: promoCode.code, + rewardAmount: promoCode.rewardAmount, + maxUses: promoCode.maxUses, + expiresAt: promoCode.expiresAt, + isActive: promoCode.isActive, + description: promoCode.description + }) + .from(promoCode) + .where(eq(promoCode.code, normalizedCode)) + .limit(1); + + if (!promoData) { + return json({ error: 'Invalid promo code' }, { status: 400 }); + } + + if (!promoData.isActive) { + return json({ error: 'This promo code is no longer active' }, { status: 400 }); + } + + if (promoData.expiresAt && new Date() > promoData.expiresAt) { + return json({ error: 'This promo code has expired' }, { status: 400 }); + } + + const [existingRedemption] = await tx + .select({ id: promoCodeRedemption.id }) + .from(promoCodeRedemption) + .where(and( + eq(promoCodeRedemption.userId, userId), + eq(promoCodeRedemption.promoCodeId, promoData.id) + )) + .limit(1); + + if (existingRedemption) { + return json({ error: 'You have already used this promo code' }, { status: 400 }); + } + + if (promoData.maxUses !== null) { + const [{ totalUses }] = await tx + .select({ totalUses: count() }) + .from(promoCodeRedemption) + .where(eq(promoCodeRedemption.promoCodeId, promoData.id)); + + if (totalUses >= promoData.maxUses) { + return json({ error: 'This promo code has reached its usage limit' }, { status: 400 }); + } + } + + const [userData] = await tx + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + if (!userData) { + throw error(404, 'User not found'); + } + + const currentBalance = Number(userData.baseCurrencyBalance || 0); + const rewardAmount = Number(promoData.rewardAmount); + const newBalance = currentBalance + rewardAmount; + + await tx + .update(user) + .set({ + baseCurrencyBalance: newBalance.toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, userId)); + + await tx + .insert(promoCodeRedemption) + .values({ + userId, + promoCodeId: promoData.id, + rewardAmount: rewardAmount.toFixed(8) + }); + + return json({ + success: true, + message: promoData.description || `Promo code redeemed! You received $${rewardAmount.toFixed(2)}`, + rewardAmount, + newBalance, + code: promoData.code + }); + }); +};