From e5db19482fdb84855e2203a17457578986dec59e Mon Sep 17 00:00:00 2001 From: blacktoast Date: Fri, 12 Dec 2025 18:40:05 +0900 Subject: [PATCH 01/19] [oko_pg_interface] add edit_customers for adding theme col --- .../20251212075514_edit_customers.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 backend/oko_pg_interface/migrations/20251212075514_edit_customers.ts diff --git a/backend/oko_pg_interface/migrations/20251212075514_edit_customers.ts b/backend/oko_pg_interface/migrations/20251212075514_edit_customers.ts new file mode 100644 index 000000000..747682058 --- /dev/null +++ b/backend/oko_pg_interface/migrations/20251212075514_edit_customers.ts @@ -0,0 +1,29 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + const hasThemeColumn: boolean = await knex.schema + .withSchema("public") + .hasColumn("customers", "theme"); + + if (hasThemeColumn) { + return; + } + + await knex.schema.withSchema("public").alterTable("customers", (table) => { + table.string("theme").notNullable().defaultTo("system"); + }); +} + +export async function down(knex: Knex): Promise { + const hasThemeColumn: boolean = await knex.schema + .withSchema("public") + .hasColumn("customers", "theme"); + + if (!hasThemeColumn) { + return; + } + + await knex.schema.withSchema("public").alterTable("customers", (table) => { + table.dropColumn("theme"); + }); +} From 4fdda15f4b2c03c0222a1dd1d067ad77bb59fc97 Mon Sep 17 00:00:00 2001 From: blacktoast Date: Mon, 15 Dec 2025 13:55:29 +0900 Subject: [PATCH 02/19] [backend] update customer's api of edit,create to store theme --- backend/admin_api/src/api/customer/index.ts | 7 +++++++ backend/ct_dashboard_api/src/routes/customer.ts | 14 +++++++++++++- backend/oko_pg_interface/src/customers/index.ts | 14 +++++++++++--- backend/openapi/src/ct_dashboard/customer.ts | 3 +++ backend/openapi/src/oko_admin/customer.ts | 4 ++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/backend/admin_api/src/api/customer/index.ts b/backend/admin_api/src/api/customer/index.ts index 6f9b50416..642e7fd5e 100644 --- a/backend/admin_api/src/api/customer/index.ts +++ b/backend/admin_api/src/api/customer/index.ts @@ -12,6 +12,7 @@ import type { } from "@oko-wallet/oko-types/admin"; import type { Customer, + CustomerTheme, CustomerWithAPIKeys, } from "@oko-wallet/oko-types/customers"; import { uploadToS3 } from "@oko-wallet/aws"; @@ -92,6 +93,11 @@ export async function createCustomer( }; } + const theme: CustomerTheme = + body.theme === "light" || body.theme === "dark" || body.theme === "system" + ? body.theme + : "system"; + const customer_id = uuidv4(); let logo_url: string | null = null; @@ -197,6 +203,7 @@ export async function createCustomer( url: body.url || null, logo_url, status: "ACTIVE", + theme, }; const insertCustomerRes = await insertCustomer(client, customer); if (insertCustomerRes.success === false) { diff --git a/backend/ct_dashboard_api/src/routes/customer.ts b/backend/ct_dashboard_api/src/routes/customer.ts index 0ac07cf28..0cf2b2164 100644 --- a/backend/ct_dashboard_api/src/routes/customer.ts +++ b/backend/ct_dashboard_api/src/routes/customer.ts @@ -3,6 +3,7 @@ import sharp from "sharp"; import { randomUUID } from "crypto"; import type { Customer, + CustomerTheme, UpdateCustomerInfoRequest, UpdateCustomerInfoResponse, } from "@oko-wallet/oko-types/customers"; @@ -337,7 +338,7 @@ export function setCustomerRoutes(router: Router) { try { const state = req.app.locals as any; const userId = res.locals.user_id; - const { label, url, delete_logo } = req.body; + const { label, url, delete_logo, theme } = req.body; const shouldDeleteLogo = delete_logo === "true"; @@ -449,6 +450,7 @@ export function setCustomerRoutes(router: Router) { label?: string; url?: string | null; logo_url?: string | null; + theme?: CustomerTheme; } = {}; if (label !== undefined && label.trim() !== "") { updates.label = label.trim(); @@ -459,6 +461,16 @@ export function setCustomerRoutes(router: Router) { if (shouldUpdateLogo) { updates.logo_url = logo_url; } + if (theme === "light" || theme === "dark" || theme === "system") { + updates.theme = theme; + } else if (theme !== undefined) { + res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: "theme must be one of light, dark, system", + }); + return; + } if (Object.keys(updates).length === 0) { res.status(400).json({ diff --git a/backend/oko_pg_interface/src/customers/index.ts b/backend/oko_pg_interface/src/customers/index.ts index e6818b504..dd6781854 100644 --- a/backend/oko_pg_interface/src/customers/index.ts +++ b/backend/oko_pg_interface/src/customers/index.ts @@ -1,6 +1,6 @@ import { Pool, type PoolClient } from "pg"; import type { Result } from "@oko-wallet/stdlib-js"; -import { type Customer } from "@oko-wallet/oko-types/customers"; +import type { Customer, CustomerTheme } from "@oko-wallet/oko-types/customers"; import { type CustomerAndCTDUser } from "@oko-wallet/oko-types/ct_dashboard"; import type { DeleteCustomerRequest, @@ -14,11 +14,11 @@ export async function insertCustomer( const query = ` INSERT INTO customers ( customer_id, label, status, - url, logo_url + url, logo_url, theme ) VALUES ( $1, $2, $3, - $4, $5 + $4, $5, COALESCE($6, 'system') ) RETURNING * `; @@ -30,6 +30,7 @@ RETURNING * customer.status, customer.url?.length ? customer.url : null, customer.logo_url?.length ? customer.logo_url : null, + customer.theme ?? "system", ]; const res = await db.query(query, values); @@ -244,6 +245,7 @@ export async function updateCustomerInfo( label?: string; url?: string | null; logo_url?: string | null; + theme?: string; }, ): Promise> { try { @@ -269,6 +271,12 @@ export async function updateCustomerInfo( paramIndex++; } + if (updates.theme !== undefined) { + updateFields.push(`theme = $${paramIndex}`); + values.push(updates.theme); + paramIndex++; + } + if (updateFields.length === 0) { return { success: false, diff --git a/backend/openapi/src/ct_dashboard/customer.ts b/backend/openapi/src/ct_dashboard/customer.ts index b140ad40e..17bb436e5 100644 --- a/backend/openapi/src/ct_dashboard/customer.ts +++ b/backend/openapi/src/ct_dashboard/customer.ts @@ -20,6 +20,9 @@ export const CustomerSchema = registry.register( logo_url: z.string().nullable().optional().openapi({ description: "Customer logo URL", }), + theme: z.string().openapi({ + description: "Customer theme it returns 'light', 'dark' or 'system'", + }), }), ); diff --git a/backend/openapi/src/oko_admin/customer.ts b/backend/openapi/src/oko_admin/customer.ts index 5c7894e0c..28e23aff9 100644 --- a/backend/openapi/src/oko_admin/customer.ts +++ b/backend/openapi/src/oko_admin/customer.ts @@ -205,6 +205,10 @@ export const CreateCustomerWithDashboardUserRequestSchema = registry.register( url: z.string().optional().openapi({ description: "Customer URL (optional)", }), + theme: z.enum(["light", "dark", "system"]).optional().openapi({ + description: "Customer theme (optional). One of light, dark, system", + example: "system", + }), logo: z.any().optional().openapi({ description: "Customer logo file (image)", format: "binary", From fceb364dab630f32aae45e1c5d116bee0010983e Mon Sep 17 00:00:00 2001 From: blacktoast Date: Mon, 15 Dec 2025 15:16:43 +0900 Subject: [PATCH 03/19] [customer_dashboard] add theme button and some guardrail for blocking empty url --- .../edit_info_form/edit_info_form.module.scss | 58 +++++++++++++++ .../edit_info_form/edit_info_form.tsx | 74 ++++++++++++++++++- .../customer_dashboard/src/fetch/customers.ts | 7 ++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/apps/customer_dashboard/src/components/edit_info_form/edit_info_form.module.scss b/apps/customer_dashboard/src/components/edit_info_form/edit_info_form.module.scss index 3f2965347..0d4763f73 100644 --- a/apps/customer_dashboard/src/components/edit_info_form/edit_info_form.module.scss +++ b/apps/customer_dashboard/src/components/edit_info_form/edit_info_form.module.scss @@ -124,3 +124,61 @@ width: 100%; margin-top: 8px; } + +.themeSection { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.themeHeader { + display: flex; + flex-direction: column; + gap: 4px; +} + +.themeLabel { + color: var(--text-secondary); + font-size: 14px; + font-weight: 600; + line-height: 20px; +} + +.themeDescription { + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + line-height: 18px; +} + +.themeOptions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.themeOptionButton { + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--border-primary); + background: var(--bg-primary); + color: var(--text-primary); + + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: #d0d5dd; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } +} + +.themeOptionButtonActive { + background: var(--bg-brand-solid); + color: var(--text-primary-on-brand); +} diff --git a/apps/customer_dashboard/src/components/edit_info_form/edit_info_form.tsx b/apps/customer_dashboard/src/components/edit_info_form/edit_info_form.tsx index 401801209..2a05734c5 100644 --- a/apps/customer_dashboard/src/components/edit_info_form/edit_info_form.tsx +++ b/apps/customer_dashboard/src/components/edit_info_form/edit_info_form.tsx @@ -7,12 +7,16 @@ import { Input } from "@oko-wallet/oko-common-ui/input"; import { Button } from "@oko-wallet/oko-common-ui/button"; import { PlusIcon } from "@oko-wallet/oko-common-ui/icons/plus"; import { XCloseIcon } from "@oko-wallet/oko-common-ui/icons/x_close"; +import type { CustomerTheme } from "@oko-wallet/oko-types/customers"; +import { Typography } from "@oko-wallet/oko-common-ui/typography"; import { useCustomerInfo } from "@oko-wallet-ct-dashboard/hooks/use_customer_info"; import { useAppState } from "@oko-wallet-ct-dashboard/state"; import { requestUpdateCustomerInfo } from "@oko-wallet-ct-dashboard/fetch/customers"; import styles from "./edit_info_form.module.scss"; +const THEME_OPTIONS: CustomerTheme[] = ["light", "dark", "system"]; + export const EditInfoForm = () => { const router = useRouter(); const queryClient = useQueryClient(); @@ -23,6 +27,10 @@ export const EditInfoForm = () => { const [label, setLabel] = useState(customer.data?.label ?? ""); const [url, setUrl] = useState(customer.data?.url ?? ""); + const [theme, setTheme] = useState( + customer.data?.theme ?? "system", + ); + const [logoFile, setLogoFile] = useState(null); const [previewUrl, setPreviewUrl] = useState( customer.data?.logo_url ?? null, @@ -147,12 +155,23 @@ export const EditInfoForm = () => { const hasLabelChange = label !== customer.data?.label; const hasUrlChange = url !== (customer.data?.url ?? ""); const hasLogoChange = logoFile !== null || shouldDeleteLogo; + const hasThemeChange = theme !== customer.data?.theme; - if (!hasLabelChange && !hasUrlChange && !hasLogoChange) { + if (!hasLabelChange && !hasUrlChange && !hasLogoChange && !hasThemeChange) { setError("No changes to save."); return; } + if (!url || url.trim() === "") { + setError("App URL is required."); + return; + } + + if (hasUrlChange && !validateUrl(url)) { + setError("App URL format is invalid."); + return; + } + setIsLoading(true); setError(null); @@ -162,6 +181,7 @@ export const EditInfoForm = () => { label: hasLabelChange ? label : undefined, url: hasUrlChange ? url : undefined, logoFile: logoFile, + theme: hasThemeChange ? theme : undefined, deleteLogo: shouldDeleteLogo, }); @@ -189,7 +209,8 @@ export const EditInfoForm = () => { label !== customer.data?.label || url !== (customer.data?.url ?? "") || logoFile !== null || - shouldDeleteLogo; + shouldDeleteLogo || + theme !== customer.data?.theme; return (
@@ -213,6 +234,46 @@ export const EditInfoForm = () => { className={styles.input} /> +
+
+ Oko Wallet Theme + + Choose the default theme for the Oko wallet. + +
+ +
+ {THEME_OPTIONS.map((option) => { + const label = + option === "system" + ? "System" + : option === "light" + ? "Light" + : "Dark"; + + return ( + + ); + })} +
+
+ {/* Logo Upload with drag & drop */}