Phase 5.x payment revamp + Xendit Stage-8 prep
- Backend wraps idn-finlogos npm at /assets/payment-icons/<slug>.svg with
1y immutable cache. Mobile drops bundled SVGs (only placeholder remains)
and fetches via flutter_cache_manager. payment_methods.icon is now a
CSV of slugs; catalog emits icon_urls[]. CARDS tile renders Visa + MC +
JCB side by side.
- Per-method min/max amount bounds (BIGINT, nullable). Picker greys out
out-of-range tiles with subtitle; backend gates with INVALID_PAYMENT_AMOUNT
(422). Defense in depth against stale-catalog clients.
- Xendit channel codes corrected from authoritative docs
(BCA_VA -> BCA_VIRTUAL_ACCOUNT, CREDIT_CARD -> CARDS, ovo -> ovo-new,
shopeepay -> shopee-pay, ...). 18 methods x 5 groups seeded with
Xendit-published per-channel min/max.
- Re-runnable seed (ON CONFLICT DO NOTHING on payment_code + new unique
index on group name). Operator CC edits never clobbered across re-runs.
One-shot reset + inspect scripts under backend/.dev/.
- Customer redirect HTML pages at /payment/return/{success,failure},
brand-styled with "Buka HaloBestie" CTA firing halobestie:// deeplink.
URL scheme registered on Android (intent-filter w/ BROWSABLE on
MainActivity) and iOS (CFBundleURLTypes). Waiting-payment poller still
owns confirmation; deeplink just brings the activity to foreground.
- Control center payment-catalog page: min/max inputs + columns. Other
CC pages restyled with new theme tokens (separate work, bundled here).
169/169 backend tests pass. See requirement/phase5-payment-revamp-2026-05-27.md
for the full revamp doc. Stage 8 (E2E) still pending: webhook URL routing
decision + two client_app follow-ups (legacy /chat/request removal,
extension Custom Tab).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
31
backend/.dev/inspect-payment-catalog.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Dump the current payment catalog for a quick visual sanity check after a
|
||||
// reset. Read-only.
|
||||
|
||||
import 'dotenv/config'
|
||||
import { getDb } from '../src/db/client.js'
|
||||
|
||||
const sql = getDb()
|
||||
const rows = await sql`
|
||||
SELECT g.name AS grp, m.display_name, m.payment_code, m.icon,
|
||||
m.min_amount, m.max_amount, m.is_active
|
||||
FROM payment_methods m
|
||||
JOIN payment_method_groups g ON m.group_id = g.id
|
||||
ORDER BY g.display_order, m.display_order
|
||||
`
|
||||
console.log(`${rows.length} methods across ${new Set(rows.map(r => r.grp)).size} groups\n`)
|
||||
const w = (s, n) => String(s).padEnd(n)
|
||||
const r = (s, n) => String(s).padStart(n)
|
||||
console.log(w('Group', 18), w('Display', 26), w('Code', 26), w('Icon', 26), r('Min', 10), r('Max', 16))
|
||||
console.log('-'.repeat(124))
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
w(row.grp, 18),
|
||||
w(row.display_name, 26),
|
||||
w(row.payment_code, 26),
|
||||
w(row.icon ?? '—', 26),
|
||||
r(row.min_amount ?? '—', 10),
|
||||
r(row.max_amount ?? '—', 16),
|
||||
row.is_active ? '' : '(inactive)',
|
||||
)
|
||||
}
|
||||
await sql.end()
|
||||
28
backend/.dev/reset-payment-catalog.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// One-shot wipe of the payment_methods + payment_method_groups tables in the
|
||||
// current DATABASE_URL. Use when you want the seed in migrate.js to repopulate
|
||||
// from scratch on the next migration run.
|
||||
//
|
||||
// Safe against payment_requests because that table does NOT FK into
|
||||
// payment_methods — `xendit_payment_method` and `product_metadata.preferred_payment_code`
|
||||
// are free-text columns.
|
||||
//
|
||||
// Usage:
|
||||
// cd backend && node .dev/reset-payment-catalog.js
|
||||
// cd backend && node src/db/migrate.js
|
||||
|
||||
import 'dotenv/config'
|
||||
import { getDb } from '../src/db/client.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const [{ count: methodCount }] = await sql`SELECT COUNT(*)::int AS count FROM payment_methods`
|
||||
const [{ count: groupCount }] = await sql`SELECT COUNT(*)::int AS count FROM payment_method_groups`
|
||||
console.log(`Before: ${methodCount} methods, ${groupCount} groups`)
|
||||
|
||||
// FK is payment_methods.group_id → payment_method_groups (ON DELETE RESTRICT),
|
||||
// so methods must go first.
|
||||
await sql`DELETE FROM payment_methods`
|
||||
await sql`DELETE FROM payment_method_groups`
|
||||
|
||||
console.log('Wiped. Now run: node src/db/migrate.js')
|
||||
await sql.end()
|
||||
34
backend/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"fastify": "^5.0.0",
|
||||
"firebase-admin": "^12.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"idn-finlogos": "^2.3.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^3.2.2",
|
||||
@@ -2642,6 +2643,39 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/idn-finlogos": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/idn-finlogos/-/idn-finlogos-2.3.0.tgz",
|
||||
"integrity": "sha512-s6kF3gPvcW+hdRJdMomKiH7m05X77VAltj2Z1FBQuP00pJxBMDULTOjK4bCsrq6LQ8xVATMGR130fSzdidwKUA==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-native": ">=0.60",
|
||||
"react-native-svg": ">=12.0",
|
||||
"svelte": ">=4.0",
|
||||
"vue": ">=3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-svg": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"fastify": "^5.0.0",
|
||||
"firebase-admin": "^12.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"idn-finlogos": "^2.3.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^3.2.2",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { sessionManagementRoutes } from './routes/internal/session.routes.js'
|
||||
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
|
||||
import { failedPairingsRoutes } from './routes/internal/failed-pairings.routes.js'
|
||||
import { internalPaymentCatalogRoutes } from './routes/internal/payment-catalog.routes.js'
|
||||
import { internalPaymentIconsRoutes } from './routes/internal/payment-icons.routes.js'
|
||||
import { internalTestRoutes } from './routes/internal/_test.routes.js'
|
||||
import { errorHandler } from './plugins/error-handler.js'
|
||||
|
||||
@@ -40,6 +41,7 @@ export const buildInternalApp = async () => {
|
||||
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
|
||||
app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' })
|
||||
app.register(internalPaymentCatalogRoutes, { prefix: '/internal' })
|
||||
app.register(internalPaymentIconsRoutes, { prefix: '/internal' })
|
||||
|
||||
// Dev/test-only — never registered in production builds.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes
|
||||
import { sharedSupportRoutes } from './routes/public/shared.support.routes.js'
|
||||
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
||||
import { paymentWebhookRoutes } from './routes/public/shared.payment-webhooks.routes.js'
|
||||
import { paymentIconRoutes } from './routes/public/shared.payment-icons.routes.js'
|
||||
import { paymentReturnRoutes } from './routes/public/payment-return.routes.js'
|
||||
import { errorHandler } from './plugins/error-handler.js'
|
||||
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
|
||||
|
||||
@@ -47,6 +49,10 @@ export const buildPublicApp = async () => {
|
||||
app.register(sharedSupportRoutes, { prefix: '/api/shared' })
|
||||
// Payment provider webhooks. Public + token-authed via x-callback-token.
|
||||
app.register(paymentWebhookRoutes, { prefix: '/api/shared/payment' })
|
||||
// Brand-mark SVGs from idn-finlogos. Public, 1-year immutable cache.
|
||||
app.register(paymentIconRoutes, { prefix: '/assets' })
|
||||
// Xendit customer-redirect HTML pages. Public, no auth.
|
||||
app.register(paymentReturnRoutes, { prefix: '/payment' })
|
||||
|
||||
// WebSocket route (registered at app level, not prefixed)
|
||||
registerWebSocketRoute(app)
|
||||
|
||||
@@ -1191,57 +1191,112 @@ const migrate = async () => {
|
||||
ON payment_methods (group_id, display_order)
|
||||
`
|
||||
|
||||
// Seed: only when the groups table is empty. Once seeded, operators edit via
|
||||
// CC; we never re-seed (avoid clobbering custom orderings).
|
||||
const [{ n: groupCount }] = await sql`
|
||||
SELECT COUNT(*)::int AS n FROM payment_method_groups
|
||||
// Per-method amount bounds (Phase 5.x). Both inclusive, both nullable
|
||||
// (null = no bound). The customer app greys out methods whose bounds the
|
||||
// current bill misses; the backend rejects with INVALID_PAYMENT_AMOUNT.
|
||||
// BIGINT so we can store Xendit's published per-channel ceilings verbatim
|
||||
// (some banks document up to Rp 50 billion, well past INT range).
|
||||
await sql`
|
||||
ALTER TABLE payment_methods
|
||||
ADD COLUMN IF NOT EXISTS min_amount BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS max_amount BIGINT
|
||||
`
|
||||
if (groupCount === 0) {
|
||||
|
||||
// Unique on group name so we can re-run the seed below with ON CONFLICT.
|
||||
await sql`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS payment_method_groups_name_uq
|
||||
ON payment_method_groups (name)
|
||||
`
|
||||
|
||||
// --- Catalog seed -----------------------------------------------------------
|
||||
//
|
||||
// Re-runnable: groups upsert via ON CONFLICT on name; methods via ON CONFLICT
|
||||
// on payment_code (already UNIQUE). Operator edits in CC are NOT clobbered
|
||||
// because DO NOTHING leaves existing rows alone. New methods you add to this
|
||||
// list later land on the next migration; existing methods don't get bumped.
|
||||
//
|
||||
// Limits are pulled from https://docs.xendit.co/docs/available-payment-channels
|
||||
// (verified 2026-05-27). Amounts are inclusive Rp. Where Xendit's documented
|
||||
// max exceeds 50B, we keep the literal number — `payment_requests.amount` is
|
||||
// INTEGER-capped at 2.1B so we never get that high in practice; the bound is
|
||||
// recorded faithfully for documentation / future raises.
|
||||
const PAYMENT_CATALOG_SEED = [
|
||||
{
|
||||
name: 'Paling Cepat',
|
||||
order: 0,
|
||||
methods: [
|
||||
{ code: 'QRIS', display: 'QRIS', icon: 'qris', active: true },
|
||||
{ code: 'QRIS', display: 'QRIS', icon: 'qris',
|
||||
min: 1500, max: 20000000, active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E-Wallet',
|
||||
order: 1,
|
||||
methods: [
|
||||
{ code: 'OVO', display: 'OVO', icon: 'ovo', active: true },
|
||||
{ code: 'DANA', display: 'DANA', icon: 'dana', active: true },
|
||||
{ code: 'SHOPEEPAY', display: 'ShopeePay', icon: 'shopeepay', active: true },
|
||||
// Xendit Invoice API doesn't expose GoPay (Gojek/GoPay relationship).
|
||||
// Seeded as inactive so it surfaces in CC but is hidden from the app
|
||||
// until we either confirm a Xendit channel or remove it entirely.
|
||||
{ code: 'GOPAY', display: 'GoPay', icon: 'gopay', active: false },
|
||||
{ code: 'OVO', display: 'OVO', icon: 'ovo-new', min: 100, max: 20000000, active: true },
|
||||
{ code: 'DANA', display: 'DANA', icon: 'dana', min: 100, max: 20000000, active: true },
|
||||
{ code: 'SHOPEEPAY', display: 'ShopeePay', icon: 'shopee-pay', min: 1, max: 20000000, active: true },
|
||||
{ code: 'LINKAJA', display: 'LinkAja', icon: 'linkaja', min: 1, max: 20000000, active: true },
|
||||
{ code: 'ASTRAPAY', display: 'AstraPay', icon: 'astra-pay', min: 100, max: 20000000, active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Virtual Account',
|
||||
order: 2,
|
||||
methods: [
|
||||
{ code: 'BCA_VA', display: 'BCA Virtual Account', icon: 'bca', active: true },
|
||||
{ code: 'MANDIRI_VA', display: 'Mandiri Virtual Account', icon: 'mandiri', active: true },
|
||||
{ code: 'BNI_VA', display: 'BNI Virtual Account', icon: 'bni', active: true },
|
||||
{ code: 'BRI_VA', display: 'BRI Virtual Account', icon: 'bri', active: true },
|
||||
{ code: 'PERMATA_VA', display: 'Permata Virtual Account', icon: 'permata', active: true },
|
||||
{ code: 'BCA_VIRTUAL_ACCOUNT', display: 'BCA Virtual Account', icon: 'bca', min: 10000, max: 50000000, active: true },
|
||||
{ code: 'MANDIRI_VIRTUAL_ACCOUNT', display: 'Mandiri Virtual Account', icon: 'mandiri', min: 1, max: 50000000000n, active: true },
|
||||
{ code: 'BNI_VIRTUAL_ACCOUNT', display: 'BNI Virtual Account', icon: 'bni', min: 1, max: 50000000, active: true },
|
||||
{ code: 'BRI_VIRTUAL_ACCOUNT', display: 'BRI Virtual Account', icon: 'bri', min: 1, max: 50000000000n, active: true },
|
||||
{ code: 'BSI_VIRTUAL_ACCOUNT', display: 'BSI Virtual Account', icon: 'bsi', min: 1, max: 50000000000n, active: true },
|
||||
{ code: 'PERMATA_VIRTUAL_ACCOUNT', display: 'Permata Virtual Account', icon: 'permata', min: 1, max: 9999999999n, active: true },
|
||||
{ code: 'CIMB_VIRTUAL_ACCOUNT', display: 'CIMB Virtual Account', icon: 'cimb-niaga', min: 1, max: 50000000, active: true },
|
||||
{ code: 'BJB_VIRTUAL_ACCOUNT', display: 'BJB Virtual Account', icon: 'bank-bjb', min: 1, max: 2000000000, active: true },
|
||||
{ code: 'BSS_VIRTUAL_ACCOUNT', display: 'BSS Virtual Account', icon: 'bank-sahabat-sampoerna', min: 1, max: 50000000000n, active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Outlet',
|
||||
order: 3,
|
||||
methods: [
|
||||
{ code: 'ALFAMART', display: 'Alfamart', icon: 'alfamart', min: 10000, max: 5000000, active: true },
|
||||
{ code: 'INDOMARET', display: 'Indomaret', icon: 'indomaret', min: 10000, max: 2500000, active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Kartu Kredit',
|
||||
order: 4,
|
||||
methods: [
|
||||
// `icon` is comma-separated → backend emits multiple icon_urls; client
|
||||
// renders them side-by-side on the same tile.
|
||||
{ code: 'CARDS', display: 'Kartu Kredit', icon: 'visa,mastercard,jcb',
|
||||
min: 5000, max: 200000000, active: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for (const group of PAYMENT_CATALOG_SEED) {
|
||||
const [{ id: groupId }] = await sql`
|
||||
// INSERT new group OR fetch existing's id. ON CONFLICT (name) DO NOTHING
|
||||
// returns no row, so we RETURNING + fallback SELECT.
|
||||
const ins = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES (${group.name}, ${group.order}, true)
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
RETURNING id
|
||||
`
|
||||
let groupId
|
||||
if (ins.length > 0) {
|
||||
groupId = ins[0].id
|
||||
} else {
|
||||
const [row] = await sql`SELECT id FROM payment_method_groups WHERE name = ${group.name}`
|
||||
groupId = row.id
|
||||
}
|
||||
let methodOrder = 0
|
||||
for (const m of group.methods) {
|
||||
await sql`
|
||||
INSERT INTO payment_methods (
|
||||
group_id, display_name, payment_code, display_order, icon, is_active
|
||||
group_id, display_name, payment_code, display_order, icon,
|
||||
min_amount, max_amount, is_active
|
||||
)
|
||||
VALUES (
|
||||
${groupId},
|
||||
@@ -1249,13 +1304,14 @@ const migrate = async () => {
|
||||
${m.code},
|
||||
${methodOrder++},
|
||||
${m.icon},
|
||||
${m.min},
|
||||
${m.max},
|
||||
${m.active}
|
||||
)
|
||||
ON CONFLICT (payment_code) DO NOTHING
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migration complete.')
|
||||
await sql.end()
|
||||
|
||||
@@ -53,6 +53,22 @@ const conflict = (message, extra = {}) => ({
|
||||
error: { code: 'CONFLICT', message, ...extra },
|
||||
})
|
||||
|
||||
// Amount bounds are inclusive Rupiah, null = no bound. Accept either null or
|
||||
// a non-negative finite integer. We coerce empty string / "" to null on input
|
||||
// so the CC form can clear a bound by submitting an empty field.
|
||||
const normalizeAmountBound = (raw) => {
|
||||
if (raw === null || raw === undefined || raw === '') return null
|
||||
return raw
|
||||
}
|
||||
|
||||
const validateAmountBound = (fieldName, raw) => {
|
||||
if (raw === null || raw === undefined || raw === '') return null
|
||||
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0) {
|
||||
return validation(`${fieldName} must be a non-negative integer or null`, fieldName)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CC_USER) {
|
||||
return reply.code(403).send({
|
||||
@@ -157,7 +173,8 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
})
|
||||
|
||||
app.post('/payment-methods', { preHandler: WRITE_GUARD }, async (request, reply) => {
|
||||
const { group_id, display_name, payment_code, display_order, icon, is_active } = request.body ?? {}
|
||||
const { group_id, display_name, payment_code, display_order, icon,
|
||||
min_amount, max_amount, is_active } = request.body ?? {}
|
||||
if (typeof group_id !== 'string' || !UUID_RE.test(group_id)) {
|
||||
return reply.code(422).send(validation('group_id is required and must be a UUID', 'group_id'))
|
||||
}
|
||||
@@ -167,6 +184,13 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
if (typeof payment_code !== 'string' || payment_code.trim().length === 0) {
|
||||
return reply.code(422).send(validation('payment_code is required', 'payment_code'))
|
||||
}
|
||||
const minErr = validateAmountBound('min_amount', min_amount)
|
||||
if (minErr) return reply.code(422).send(minErr)
|
||||
const maxErr = validateAmountBound('max_amount', max_amount)
|
||||
if (maxErr) return reply.code(422).send(maxErr)
|
||||
if (min_amount != null && max_amount != null && min_amount > max_amount) {
|
||||
return reply.code(422).send(validation('min_amount must be <= max_amount', 'min_amount'))
|
||||
}
|
||||
try {
|
||||
const row = await createMethod({
|
||||
groupId: group_id,
|
||||
@@ -174,6 +198,8 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
paymentCode: payment_code.trim(),
|
||||
displayOrder: Number.isFinite(display_order) ? display_order : 0,
|
||||
icon: typeof icon === 'string' && icon.trim().length > 0 ? icon.trim() : null,
|
||||
minAmount: normalizeAmountBound(min_amount),
|
||||
maxAmount: normalizeAmountBound(max_amount),
|
||||
isActive: typeof is_active === 'boolean' ? is_active : true,
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: row })
|
||||
@@ -198,7 +224,8 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
if (!UUID_RE.test(id)) {
|
||||
return reply.code(422).send(validation('Invalid id format', 'id'))
|
||||
}
|
||||
const { group_id, display_name, payment_code, display_order, icon, is_active } = request.body ?? {}
|
||||
const body = request.body ?? {}
|
||||
const { group_id, display_name, payment_code, display_order, icon, is_active } = body
|
||||
if (group_id !== undefined && (typeof group_id !== 'string' || !UUID_RE.test(group_id))) {
|
||||
return reply.code(422).send(validation('group_id must be a UUID', 'group_id'))
|
||||
}
|
||||
@@ -208,6 +235,24 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
if (payment_code !== undefined && (typeof payment_code !== 'string' || payment_code.trim().length === 0)) {
|
||||
return reply.code(422).send(validation('payment_code must be non-empty', 'payment_code'))
|
||||
}
|
||||
const hasMin = Object.prototype.hasOwnProperty.call(body, 'min_amount')
|
||||
const hasMax = Object.prototype.hasOwnProperty.call(body, 'max_amount')
|
||||
if (hasMin) {
|
||||
const err = validateAmountBound('min_amount', body.min_amount)
|
||||
if (err) return reply.code(422).send(err)
|
||||
}
|
||||
if (hasMax) {
|
||||
const err = validateAmountBound('max_amount', body.max_amount)
|
||||
if (err) return reply.code(422).send(err)
|
||||
}
|
||||
// Cross-field check uses post-patch effective values. If only one side is
|
||||
// patched, the other comes from the existing row — fetched in the service
|
||||
// would be cleaner, but we'd need a SELECT round-trip; instead, require
|
||||
// the operator to send both when narrowing the range.
|
||||
if (hasMin && hasMax && body.min_amount != null && body.max_amount != null
|
||||
&& body.min_amount > body.max_amount) {
|
||||
return reply.code(422).send(validation('min_amount must be <= max_amount', 'min_amount'))
|
||||
}
|
||||
try {
|
||||
const row = await updateMethod(id, {
|
||||
groupId: group_id,
|
||||
@@ -215,6 +260,8 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
paymentCode: payment_code?.trim(),
|
||||
displayOrder: Number.isFinite(display_order) ? display_order : undefined,
|
||||
icon: icon === null ? null : (typeof icon === 'string' && icon.trim().length > 0 ? icon.trim() : undefined),
|
||||
...(hasMin ? { minAmount: normalizeAmountBound(body.min_amount) } : {}),
|
||||
...(hasMax ? { maxAmount: normalizeAmountBound(body.max_amount) } : {}),
|
||||
isActive: typeof is_active === 'boolean' ? is_active : undefined,
|
||||
})
|
||||
if (!row) return reply.code(404).send(notFound('payment_method not found'))
|
||||
|
||||
40
backend/src/routes/internal/payment-icons.routes.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Control-center read-only manifest for the payment-icon picker.
|
||||
*
|
||||
* GET /internal/payment-icons → { slugs: [...] }
|
||||
*
|
||||
* The CC payment-method form uses this to populate a dropdown of valid
|
||||
* `icon` values, so operators pick from a known list instead of typing free
|
||||
* text and risking a 404 on the asset endpoint. Reuses the `config` `read`
|
||||
* permission (same scope used by the catalog editor).
|
||||
*/
|
||||
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserById } from '../../services/cc-user.service.js'
|
||||
import { UserType } from '../../constants.js'
|
||||
import { listIconSlugs } from '../../services/payment-icon.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CC_USER) {
|
||||
return reply.code(403).send({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Not a control center user' },
|
||||
})
|
||||
}
|
||||
const user = await getCcUserById(request.auth.userId)
|
||||
if (!user) {
|
||||
return reply.code(403).send({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Not a control center user' },
|
||||
})
|
||||
}
|
||||
request.ccUser = user
|
||||
}
|
||||
|
||||
const READ_GUARD = [authenticate, attachCcUser, requirePermission('config', 'read')]
|
||||
|
||||
export const internalPaymentIconsRoutes = async (app) => {
|
||||
app.get('/payment-icons', { preHandler: READ_GUARD }, async (_request, reply) => {
|
||||
return reply.send({ success: true, data: { slugs: listIconSlugs() } })
|
||||
})
|
||||
}
|
||||
@@ -59,6 +59,9 @@ export const clientPaymentRoutes = async (app) => {
|
||||
// must reference an active row in `payment_methods`. Casing-tolerant; older app
|
||||
// versions sending lower-case (`qris`) are normalised inside the service.
|
||||
// `method` is optional for backwards compat with pre-Phase-5.x callers.
|
||||
// Amount-bound enforcement happens AFTER amount is computed below; we
|
||||
// capture `methodEntry` here to avoid a second lookup.
|
||||
let methodEntry = null
|
||||
if (method !== null && method !== undefined) {
|
||||
if (typeof method !== 'string' || method.trim().length === 0) {
|
||||
return reply.code(422).send({
|
||||
@@ -66,8 +69,8 @@ export const clientPaymentRoutes = async (app) => {
|
||||
error: { code: 'VALIDATION_ERROR', message: 'method must be a non-empty string when provided' },
|
||||
})
|
||||
}
|
||||
const entry = await findActiveMethodByCode(method)
|
||||
if (!entry) {
|
||||
methodEntry = await findActiveMethodByCode(method)
|
||||
if (!methodEntry) {
|
||||
return reply.code(422).send({
|
||||
success: false,
|
||||
error: {
|
||||
@@ -129,6 +132,32 @@ export const clientPaymentRoutes = async (app) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-method amount bounds — defense in depth alongside the client's own
|
||||
// disabled-tile UX. A stale catalog cache on the client could let a request
|
||||
// through that the picker should have blocked. Bounds are inclusive.
|
||||
if (methodEntry) {
|
||||
if (methodEntry.min_amount != null && amount < methodEntry.min_amount) {
|
||||
return reply.code(422).send({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_PAYMENT_AMOUNT',
|
||||
message: 'Amount below the minimum for the selected payment method',
|
||||
details: { amount, min_amount: methodEntry.min_amount, max_amount: methodEntry.max_amount },
|
||||
},
|
||||
})
|
||||
}
|
||||
if (methodEntry.max_amount != null && amount > methodEntry.max_amount) {
|
||||
return reply.code(422).send({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_PAYMENT_AMOUNT',
|
||||
message: 'Amount above the maximum for the selected payment method',
|
||||
details: { amount, min_amount: methodEntry.min_amount, max_amount: methodEntry.max_amount },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: payment.service.js handles the Xendit invoice creation internally
|
||||
// when XENDIT_ENABLED=true. The row comes back with xendit_invoice_url populated;
|
||||
// when off, invoice_url is null and the dev/Maestro stub plays the webhook role.
|
||||
|
||||
154
backend/src/routes/public/payment-return.routes.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Customer-facing return pages for Xendit Invoice checkout — Phase 5.x.
|
||||
*
|
||||
* Routes (public, no auth — Xendit's hosted checkout 302s the customer's
|
||||
* browser here after payment):
|
||||
* GET /payment/return/success
|
||||
* GET /payment/return/failure
|
||||
*
|
||||
* Xendit appends query params like `external_id` and `payment_id`; we don't
|
||||
* need them — the customer's app polls `GET /payment-requests/:id`
|
||||
* independently and learns the outcome from there. These pages are PURE UX:
|
||||
* they confirm the outcome and offer a `halobestie://` deeplink button so
|
||||
* the customer can flip back to the app with one tap.
|
||||
*
|
||||
* The deeplink scheme must be registered in
|
||||
* `client_app/android/app/src/main/AndroidManifest.xml` and
|
||||
* `client_app/ios/Runner/Info.plist`. If the scheme isn't registered the
|
||||
* button is a no-op; the customer can still tap the Custom Tab back arrow.
|
||||
*
|
||||
* Brand colors mirror `client_app/lib/core/theme/halo_tokens.dart`:
|
||||
* brand #E17A9D
|
||||
* brandDark #8C3255
|
||||
* brandSofter #FBEFF3
|
||||
* danger (red shades — failure variant)
|
||||
*/
|
||||
|
||||
const renderPage = ({ variant, title, headline, body, deeplink }) => {
|
||||
const accent = variant === 'success' ? '#E17A9D' : '#D86B6B'
|
||||
const accentDark = variant === 'success' ? '#8C3255' : '#7A1E1E'
|
||||
const accentSoft = variant === 'success' ? '#FBEFF3' : '#FCEDED'
|
||||
const glyph = variant === 'success' ? '✓' : '!'
|
||||
return `<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>${title}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@600;700&family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--accent: ${accent};
|
||||
--accent-dark: ${accentDark};
|
||||
--accent-soft: ${accentSoft};
|
||||
--ink: #1F1419;
|
||||
--ink-soft: #6B5A62;
|
||||
--bg: #FFF7FA;
|
||||
--card: #FFFFFF;
|
||||
--border: #F0E2E8;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0; min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap {
|
||||
min-height: 100vh;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.card {
|
||||
width: 100%; max-width: 380px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 24px rgba(140, 50, 85, 0.06);
|
||||
}
|
||||
.glyph {
|
||||
width: 72px; height: 72px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 36px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: 'Bricolage Grotesque', serif;
|
||||
font-size: 40px; font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Bricolage Grotesque', serif;
|
||||
font-size: 22px; font-weight: 700;
|
||||
color: var(--accent-dark);
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
p {
|
||||
font-size: 14px; line-height: 1.55;
|
||||
color: var(--ink-soft);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.cta {
|
||||
display: block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
padding: 14px 20px;
|
||||
border-radius: 14px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.cta:hover, .cta:focus { background: var(--accent-dark); }
|
||||
.cta:active { transform: scale(0.99); }
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card" role="status" aria-live="polite">
|
||||
<div class="glyph" aria-hidden="true">${glyph}</div>
|
||||
<h1>${headline}</h1>
|
||||
<p>${body}</p>
|
||||
<a class="cta" href="${deeplink}">Buka HaloBestie</a>
|
||||
<div class="hint">Atau tutup tab ini untuk kembali ke aplikasi.</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export const paymentReturnRoutes = async (app) => {
|
||||
app.get('/return/success', async (_request, reply) => {
|
||||
return reply.type('text/html; charset=utf-8').send(renderPage({
|
||||
variant: 'success',
|
||||
title: 'Pembayaran berhasil — HaloBestie',
|
||||
headline: 'Pembayaran berhasil!',
|
||||
body: 'Sesi kamu sedang disiapkan. Buka aplikasi HaloBestie untuk mulai curhat.',
|
||||
deeplink: 'halobestie://payment/return?status=success',
|
||||
}))
|
||||
})
|
||||
|
||||
app.get('/return/failure', async (_request, reply) => {
|
||||
return reply.type('text/html; charset=utf-8').send(renderPage({
|
||||
variant: 'failure',
|
||||
title: 'Pembayaran tidak berhasil — HaloBestie',
|
||||
headline: 'Pembayaran tidak berhasil',
|
||||
body: 'Tenang, belum ada yang ditarik. Buka aplikasi HaloBestie dan coba metode lain.',
|
||||
deeplink: 'halobestie://payment/return?status=failure',
|
||||
}))
|
||||
})
|
||||
}
|
||||
40
backend/src/routes/public/shared.payment-icons.routes.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Public payment-icon serving — Phase 5.x.
|
||||
*
|
||||
* GET /assets/payment-icons/:slug.svg
|
||||
* Returns the idn-finlogos SVG for `slug` with a 1-year immutable cache
|
||||
* header. Content is stable per backend deploy (icons change only when the
|
||||
* `idn-finlogos` npm dep is bumped); clients can cache aggressively.
|
||||
*
|
||||
* Public on purpose — these are brand-mark icons, not sensitive data. The
|
||||
* catalog endpoint (`GET /api/client/payment-methods`) is still authenticated;
|
||||
* leaking the icon URL by itself reveals nothing useful.
|
||||
*
|
||||
* 404 on unknown slug. We deliberately do NOT 200-with-placeholder here —
|
||||
* upstream owns the "show placeholder" fallback, and 404ing tells operators
|
||||
* about typo'd slugs in the CC payment-method form.
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'fs'
|
||||
import { hasIconSlug, resolveIconPath } from '../../services/payment-icon.service.js'
|
||||
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/
|
||||
|
||||
export const paymentIconRoutes = async (app) => {
|
||||
app.get('/payment-icons/:slug.svg', async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
|
||||
// Guard against path-traversal / oversized slug before touching the FS.
|
||||
if (!SLUG_RE.test(slug) || !hasIconSlug(slug)) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message: 'Unknown payment icon slug' },
|
||||
})
|
||||
}
|
||||
|
||||
return reply
|
||||
.type('image/svg+xml')
|
||||
.header('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
.send(createReadStream(resolveIconPath(slug)))
|
||||
})
|
||||
}
|
||||
@@ -27,7 +27,31 @@ import { getValkeyClient, publish, subscribe } from '../plugins/valkey.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const CACHE_KEY = 'payment-catalog:v1'
|
||||
// Bump the version suffix whenever the catalog shape changes so a deploy
|
||||
// doesn't serve stale-shape entries from L2 Valkey for up to VALKEY_TTL_SECONDS.
|
||||
// v2: added icon_url alongside icon (2026-05-27, Phase 5.x backend icon hosting).
|
||||
// v3: added min_amount / max_amount per method (2026-05-27, Phase 5.x amount bounds).
|
||||
// v4: icon -> comma-separated slug list, emit icon_urls[] (2026-05-27, multi-logo for cards).
|
||||
const CACHE_KEY = 'payment-catalog:v4'
|
||||
|
||||
// Split a comma-separated `icon` string into trimmed, non-empty slugs.
|
||||
// Tolerates whitespace and stray commas: " visa , mastercard , " → ['visa','mastercard'].
|
||||
const parseIconSlugs = (raw) => {
|
||||
if (!raw) return []
|
||||
return String(raw)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
// `postgres` returns BIGINT columns as JS strings (BigInt would break JSON.stringify).
|
||||
// All realistic payment amounts fit comfortably below Number.MAX_SAFE_INTEGER, so we
|
||||
// coerce to Number for the API response — keeps the wire shape `{min_amount: 10000}`
|
||||
// rather than `"10000"`, which is what mobile/CC parsers expect.
|
||||
const coerceAmount = (v) => {
|
||||
if (v === null || v === undefined) return null
|
||||
return typeof v === 'number' ? v : Number(v)
|
||||
}
|
||||
const VALKEY_TTL_SECONDS = 60 * 60 // 1 hour
|
||||
const INPROCESS_TTL_MS = 60 * 1000 // 60 seconds
|
||||
const INVALIDATE_CHANNEL = 'config:invalidate'
|
||||
@@ -93,6 +117,8 @@ const buildCatalogFromDb = async () => {
|
||||
m.payment_code AS payment_code,
|
||||
m.display_name AS display_name,
|
||||
m.icon AS icon,
|
||||
m.min_amount AS min_amount,
|
||||
m.max_amount AS max_amount,
|
||||
m.display_order AS method_order
|
||||
FROM payment_method_groups g
|
||||
JOIN payment_methods m ON m.group_id = g.id
|
||||
@@ -111,11 +137,19 @@ const buildCatalogFromDb = async () => {
|
||||
methods: [],
|
||||
})
|
||||
}
|
||||
// `icon` is a comma-separated slug list (single entry for most methods;
|
||||
// multiple for composite tiles like a credit-card row showing Visa + MC + JCB).
|
||||
// We emit `icon_urls` as the canonical field; clients render them in a row.
|
||||
const slugs = parseIconSlugs(r.icon)
|
||||
byGroupId.get(r.group_id).methods.push({
|
||||
id: r.method_id,
|
||||
payment_code: r.payment_code,
|
||||
display_name: r.display_name,
|
||||
icon: r.icon,
|
||||
icon_urls: slugs.map((s) => `/assets/payment-icons/${s}.svg`),
|
||||
// Per-method amount bounds (inclusive). Either may be null = no bound.
|
||||
min_amount: coerceAmount(r.min_amount),
|
||||
max_amount: coerceAmount(r.max_amount),
|
||||
order: r.method_order,
|
||||
})
|
||||
}
|
||||
@@ -193,18 +227,23 @@ export const listMethods = async ({ groupId = null } = {}) => {
|
||||
const rows = groupId
|
||||
? await sql`
|
||||
SELECT id, group_id, display_name, payment_code, display_order,
|
||||
icon, is_active, created_at, updated_at
|
||||
icon, min_amount, max_amount, is_active, created_at, updated_at
|
||||
FROM payment_methods
|
||||
WHERE group_id = ${groupId}
|
||||
ORDER BY display_order ASC, display_name ASC
|
||||
`
|
||||
: await sql`
|
||||
SELECT id, group_id, display_name, payment_code, display_order,
|
||||
icon, is_active, created_at, updated_at
|
||||
icon, min_amount, max_amount, is_active, created_at, updated_at
|
||||
FROM payment_methods
|
||||
ORDER BY display_order ASC, display_name ASC
|
||||
`
|
||||
return rows
|
||||
// BIGINT → number for CC consumption (table uses toLocaleString).
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
min_amount: coerceAmount(r.min_amount),
|
||||
max_amount: coerceAmount(r.max_amount),
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Catalog mutators (used by control-center routes) ------------------------
|
||||
@@ -270,11 +309,14 @@ export const createMethod = async ({
|
||||
paymentCode,
|
||||
displayOrder = 0,
|
||||
icon = null,
|
||||
minAmount = null,
|
||||
maxAmount = null,
|
||||
isActive = true,
|
||||
}) => {
|
||||
const [row] = await sql`
|
||||
INSERT INTO payment_methods (
|
||||
group_id, display_name, payment_code, display_order, icon, is_active
|
||||
group_id, display_name, payment_code, display_order, icon,
|
||||
min_amount, max_amount, is_active
|
||||
)
|
||||
VALUES (
|
||||
${groupId},
|
||||
@@ -282,15 +324,22 @@ export const createMethod = async ({
|
||||
${String(paymentCode).toUpperCase()},
|
||||
${displayOrder},
|
||||
${icon},
|
||||
${minAmount},
|
||||
${maxAmount},
|
||||
${isActive}
|
||||
)
|
||||
RETURNING id, group_id, display_name, payment_code, display_order, icon, is_active
|
||||
RETURNING id, group_id, display_name, payment_code, display_order,
|
||||
icon, min_amount, max_amount, is_active
|
||||
`
|
||||
await invalidatePaymentCatalog()
|
||||
return row
|
||||
}
|
||||
|
||||
export const updateMethod = async (id, patch) => {
|
||||
// null is a meaningful update for min/max (operator clearing the bound), so
|
||||
// we route those through a sentinel-aware branch instead of COALESCE.
|
||||
const setMin = Object.prototype.hasOwnProperty.call(patch, 'minAmount')
|
||||
const setMax = Object.prototype.hasOwnProperty.call(patch, 'maxAmount')
|
||||
const [row] = await sql`
|
||||
UPDATE payment_methods
|
||||
SET
|
||||
@@ -299,10 +348,13 @@ export const updateMethod = async (id, patch) => {
|
||||
payment_code = COALESCE(${patch.paymentCode ? String(patch.paymentCode).toUpperCase() : null}, payment_code),
|
||||
display_order = COALESCE(${patch.displayOrder ?? null}, display_order),
|
||||
icon = COALESCE(${patch.icon ?? null}, icon),
|
||||
min_amount = ${setMin ? patch.minAmount : sql`min_amount`},
|
||||
max_amount = ${setMax ? patch.maxAmount : sql`max_amount`},
|
||||
is_active = COALESCE(${patch.isActive ?? null}, is_active),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING id, group_id, display_name, payment_code, display_order, icon, is_active
|
||||
RETURNING id, group_id, display_name, payment_code, display_order,
|
||||
icon, min_amount, max_amount, is_active
|
||||
`
|
||||
await invalidatePaymentCatalog()
|
||||
return row
|
||||
|
||||
41
backend/src/services/payment-icon.service.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Payment-icon serving — Phase 5.x icon hosting.
|
||||
*
|
||||
* Backend wraps the `idn-finlogos` npm package and serves its SVGs at
|
||||
* `/assets/payment-icons/<slug>.svg`. The catalog endpoint returns
|
||||
* `icon_url: "/assets/payment-icons/<slug>.svg"`; the client app fetches +
|
||||
* caches with `flutter_cache_manager`. No bundled icons in the app anymore.
|
||||
*
|
||||
* License: idn-finlogos ships under CC-BY-NC-4.0 for the assets (per-brand
|
||||
* permission still required for production use). Code under MIT. See
|
||||
* `backend/node_modules/idn-finlogos/LICENSE-ASSETS` + `NOTICE`.
|
||||
*
|
||||
* Slug set is loaded ONCE at module init from `dist/icons/*.svg` filenames so
|
||||
* the request path stays a single Set.has() lookup. Bump the package version
|
||||
* to add/replace icons; restart the backend to refresh the slug set.
|
||||
*/
|
||||
|
||||
import { readdirSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { createRequire } from 'module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const PACKAGE_DIR = dirname(require.resolve('idn-finlogos/package.json'))
|
||||
const ICONS_DIR = join(PACKAGE_DIR, 'dist', 'icons')
|
||||
|
||||
const SLUGS = new Set(
|
||||
readdirSync(ICONS_DIR)
|
||||
.filter((f) => f.endsWith('.svg'))
|
||||
.map((f) => f.slice(0, -4)),
|
||||
)
|
||||
|
||||
export const hasIconSlug = (slug) => typeof slug === 'string' && SLUGS.has(slug)
|
||||
|
||||
export const resolveIconPath = (slug) => {
|
||||
if (!hasIconSlug(slug)) return null
|
||||
return join(ICONS_DIR, `${slug}.svg`)
|
||||
}
|
||||
|
||||
/** Sorted slug list for the CC dropdown. */
|
||||
export const listIconSlugs = () => Array.from(SLUGS).sort()
|
||||
@@ -123,6 +123,65 @@ describe('POST /api/client/payment-requests', () => {
|
||||
expect(confirmed.confirmed_at).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects with INVALID_PAYMENT_AMOUNT when amount falls below the method min', async () => {
|
||||
// Seed a low-min method and price the request below it.
|
||||
const sql = db()
|
||||
const [g] = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES ('TestGroup-min', 99, true) RETURNING id
|
||||
`
|
||||
await sql`
|
||||
INSERT INTO payment_methods (group_id, display_name, payment_code, display_order,
|
||||
icon, min_amount, max_amount, is_active)
|
||||
VALUES (${g.id}, 'TestVA', 'TEST_VA', 0, null, 50000, null, true)
|
||||
`
|
||||
// Bust the catalog cache so the new method is visible.
|
||||
const { invalidatePaymentCatalog } = await import('../../src/services/payment-catalog.service.js')
|
||||
await invalidatePaymentCatalog()
|
||||
|
||||
// Eligible discount path puts the price at 2000 — well below TEST_VA's 50000 min.
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/client/payment-requests',
|
||||
headers: authHeader(token),
|
||||
payload: { duration_minutes: 12, method: 'TEST_VA' },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(422)
|
||||
const body = res.json()
|
||||
expect(body.error.code).toBe('INVALID_PAYMENT_AMOUNT')
|
||||
expect(body.error.details.min_amount).toBe(50000)
|
||||
expect(body.error.details.amount).toBe(2000)
|
||||
})
|
||||
|
||||
it('rejects with INVALID_PAYMENT_AMOUNT when amount exceeds the method max', async () => {
|
||||
const sql = db()
|
||||
const [g] = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES ('TestGroup-max', 99, true) RETURNING id
|
||||
`
|
||||
await sql`
|
||||
INSERT INTO payment_methods (group_id, display_name, payment_code, display_order,
|
||||
icon, min_amount, max_amount, is_active)
|
||||
VALUES (${g.id}, 'TestWallet', 'TEST_W', 0, null, null, 1000, true)
|
||||
`
|
||||
const { invalidatePaymentCatalog } = await import('../../src/services/payment-catalog.service.js')
|
||||
await invalidatePaymentCatalog()
|
||||
|
||||
// Discounted 12-min = 2000 IDR, above the 1000 max.
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/client/payment-requests',
|
||||
headers: authHeader(token),
|
||||
payload: { duration_minutes: 12, method: 'TEST_W' },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(422)
|
||||
const body = res.json()
|
||||
expect(body.error.code).toBe('INVALID_PAYMENT_AMOUNT')
|
||||
expect(body.error.details.max_amount).toBe(1000)
|
||||
})
|
||||
|
||||
it('call-mode payment session uses the call tier price group', async () => {
|
||||
// 20-minute call tier in Phase 4 = 17000 IDR.
|
||||
const res = await app.inject({
|
||||
|
||||
59
backend/test/routes/shared.payment-icons.routes.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => false),
|
||||
sendToSessionParticipant: vi.fn(() => false),
|
||||
registerWebSocketPlugin: vi.fn(async () => {}),
|
||||
registerWebSocketRoute: vi.fn(),
|
||||
isUserOnlineWs: vi.fn(() => false),
|
||||
getSessionConnections: vi.fn(() => ({})),
|
||||
}))
|
||||
vi.mock('../../src/services/notification.service.js', () => ({
|
||||
sendPushNotification: vi.fn(async () => true),
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const { buildPublic } = await import('../helpers/server.js')
|
||||
|
||||
describe('GET /assets/payment-icons/:slug.svg', () => {
|
||||
let app
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildPublic()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('serves a known idn-finlogos slug with svg content-type and immutable cache', async () => {
|
||||
// 'qris' is one of our seeded slugs and is shipped by idn-finlogos.
|
||||
const res = await app.inject({ method: 'GET', url: '/assets/payment-icons/qris.svg' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.headers['content-type']).toBe('image/svg+xml')
|
||||
expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable')
|
||||
expect(res.body).toMatch(/^<\?xml|^<svg/)
|
||||
})
|
||||
|
||||
it('404s on an unknown slug', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/assets/payment-icons/definitely-not-a-real-bank.svg',
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('404s on slug with path-traversal characters', async () => {
|
||||
// Fastify normalises `..` in the URL, so this resolves to the parent
|
||||
// route; we still expect a 404 (no SVG matches) — but the SLUG_RE guard
|
||||
// is what would catch an unencoded attempt if a router ever let one
|
||||
// through, so we cover its negative case here.
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/assets/payment-icons/UPPER.svg',
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -44,10 +44,19 @@ const insertGroup = async ({ name, order = 0, active = true }) => {
|
||||
return row.id
|
||||
}
|
||||
|
||||
const insertMethod = async ({ groupId, code, display = null, order = 0, icon = null, active = true }) => {
|
||||
const insertMethod = async ({
|
||||
groupId, code, display = null, order = 0, icon = null,
|
||||
minAmount = null, maxAmount = null, active = true,
|
||||
}) => {
|
||||
const [row] = await sql`
|
||||
INSERT INTO payment_methods (group_id, display_name, payment_code, display_order, icon, is_active)
|
||||
VALUES (${groupId}, ${display ?? code}, ${code}, ${order}, ${icon}, ${active})
|
||||
INSERT INTO payment_methods (
|
||||
group_id, display_name, payment_code, display_order, icon,
|
||||
min_amount, max_amount, is_active
|
||||
)
|
||||
VALUES (
|
||||
${groupId}, ${display ?? code}, ${code}, ${order}, ${icon},
|
||||
${minAmount}, ${maxAmount}, ${active}
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
return row.id
|
||||
@@ -116,6 +125,41 @@ describe('payment-catalog.service', () => {
|
||||
expect(groups.map((g) => g.name)).toEqual(['E-Wallet'])
|
||||
})
|
||||
|
||||
it('parses icon as comma-separated slug list into icon_urls (relative /assets paths)', async () => {
|
||||
const g = await insertGroup({ name: 'E-Wallet' })
|
||||
await insertMethod({ groupId: g, code: 'OVO', icon: 'ovo-new', order: 0 })
|
||||
await insertMethod({ groupId: g, code: 'CARDS', icon: 'visa,mastercard,jcb', order: 1 })
|
||||
await insertMethod({ groupId: g, code: 'NOICON', icon: null, order: 2 })
|
||||
|
||||
await invalidatePaymentCatalog()
|
||||
const { groups } = await getCatalogForApp()
|
||||
const methods = groups[0].methods
|
||||
expect(methods[0].icon_urls).toEqual(['/assets/payment-icons/ovo-new.svg'])
|
||||
expect(methods[1].icon_urls).toEqual([
|
||||
'/assets/payment-icons/visa.svg',
|
||||
'/assets/payment-icons/mastercard.svg',
|
||||
'/assets/payment-icons/jcb.svg',
|
||||
])
|
||||
expect(methods[2].icon_urls).toEqual([])
|
||||
})
|
||||
|
||||
it('emits min_amount + max_amount per method (null when not set)', async () => {
|
||||
const g = await insertGroup({ name: 'E-Wallet' })
|
||||
await insertMethod({ groupId: g, code: 'OVO', minAmount: 10000, maxAmount: 10000000, order: 0 })
|
||||
await insertMethod({ groupId: g, code: 'QRIS', minAmount: 1, maxAmount: null, order: 1 })
|
||||
await insertMethod({ groupId: g, code: 'BCA_VA', minAmount: null, maxAmount: null, order: 2 })
|
||||
|
||||
await invalidatePaymentCatalog()
|
||||
const { groups } = await getCatalogForApp()
|
||||
const methods = groups[0].methods
|
||||
expect(methods[0].min_amount).toBe(10000)
|
||||
expect(methods[0].max_amount).toBe(10000000)
|
||||
expect(methods[1].min_amount).toBe(1)
|
||||
expect(methods[1].max_amount).toBeNull()
|
||||
expect(methods[2].min_amount).toBeNull()
|
||||
expect(methods[2].max_amount).toBeNull()
|
||||
})
|
||||
|
||||
it('caches in-process (second call returns the same object reference)', async () => {
|
||||
const g = await insertGroup({ name: 'E-Wallet' })
|
||||
await insertMethod({ groupId: g, code: 'OVO' })
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Phase 5.x: deeplink for Xendit return pages. The button on
|
||||
https://<backend>/payment/return/{success,failure} fires
|
||||
halobestie://payment/return?status=… which lands here and
|
||||
brings the activity to the foreground. We don't consume the
|
||||
URI in Flutter today — the waiting-payment screen's poller
|
||||
detects confirmation independently. BROWSABLE + DEFAULT make
|
||||
the link follow-able from a browser/Custom Tab. -->
|
||||
<intent-filter android:autoVerify="false">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="halobestie"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
Logo Assets License — Creative Commons Attribution-NonCommercial 4.0 International
|
||||
(CC BY-NC 4.0)
|
||||
|
||||
This license applies to all SVG logo assets in `icons/` and `dist/icons/`.
|
||||
|
||||
Copyright (c) 2026 Hafidz Noor Fauzi (collection/curation only).
|
||||
|
||||
The underlying logo marks remain the property of their respective trademark
|
||||
holders. See NOTICE for details on trademark ownership and disclaimers.
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format.
|
||||
- Adapt — remix, transform, and build upon the material.
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to this
|
||||
license, and indicate if changes were made. You may do so in any
|
||||
reasonable manner, but not in any way that suggests the licensor
|
||||
endorses you or your use.
|
||||
- NonCommercial — You may not use the material for commercial purposes.
|
||||
|
||||
No additional restrictions — You may not apply legal terms or technological
|
||||
measures that legally restrict others from doing anything the license permits.
|
||||
|
||||
Full license text:
|
||||
https://creativecommons.org/licenses/by-nc/4.0/legalcode
|
||||
|
||||
Human-readable summary:
|
||||
https://creativecommons.org/licenses/by-nc/4.0/
|
||||
|
||||
THE LOGO ASSETS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
|
||||
@@ -1,79 +0,0 @@
|
||||
# Payment method icons
|
||||
|
||||
Bundled SVGs referenced by the payment catalog (`payment_methods.icon` slug
|
||||
in the backend). The customer app's `PaymentIcon` widget resolves
|
||||
`<slug>.svg` from this directory and falls back to `placeholder.svg` when the
|
||||
file isn't bundled — see `client_app/lib/features/payment/widgets/payment_icon.dart`.
|
||||
|
||||
## Naming convention
|
||||
|
||||
One SVG per slug, lower-case, hyphens not underscores. Currently bundled
|
||||
slugs (match the migrate.js seed):
|
||||
|
||||
| Slug | Method | Source filename in `idn-finlogos/icons/` |
|
||||
|---|---|---|
|
||||
| `qris` | QRIS | `qris.svg` |
|
||||
| `ovo` | OVO | `ovo-new.svg` |
|
||||
| `dana` | DANA | `dana.svg` |
|
||||
| `shopeepay` | ShopeePay | `shopee-pay.svg` |
|
||||
| `gopay` | GoPay (seeded inactive) | `gopay.svg` |
|
||||
| `bca` | BCA Virtual Account | `bca.svg` |
|
||||
| `mandiri` | Mandiri Virtual Account | `mandiri.svg` |
|
||||
| `bni` | BNI Virtual Account | `bni.svg` |
|
||||
| `bri` | BRI Virtual Account | `bri.svg` |
|
||||
| `permata` | Permata Virtual Account | `permata.svg` |
|
||||
|
||||
## Sourcing — idn-finlogos
|
||||
|
||||
We pull individual SVGs from
|
||||
[github.com/hafidznoor/idn-finlogos](https://github.com/hafidznoor/idn-finlogos)
|
||||
rather than installing the Flutter package (`idn_finlogos` on pub.dev). The
|
||||
package bundles all 572 SVGs as Flutter assets — Flutter doesn't tree-shake
|
||||
assets, so adding the package ships ~4-6 MB of marks we never use. Manual
|
||||
copy keeps the APK lean: 10 marks ≈ 80 KB on-disk.
|
||||
|
||||
See `NOTICE_IDN_FINLOGOS.txt` for the upstream licensing terms (MIT for build
|
||||
tooling, CC BY-NC 4.0 for the SVG assets, plus the project's note that
|
||||
individual brand marks require permission from each brand holder for
|
||||
commercial use).
|
||||
|
||||
## Adding a new method
|
||||
|
||||
1. **Catalog row** (control center → Payment Catalog):
|
||||
- Add a method with `payment_code` set to the Xendit channel code
|
||||
(uppercase, e.g. `LINKAJA`) and `icon` set to the desired slug
|
||||
(lowercase, e.g. `linkaja`).
|
||||
- Method renders immediately with the generic placeholder icon.
|
||||
2. **Branded SVG** (one-time, requires an app release):
|
||||
```sh
|
||||
cd client_app/assets/payment_icons
|
||||
curl -sS https://raw.githubusercontent.com/hafidznoor/idn-finlogos/main/icons/<source>.svg -o <slug>.svg
|
||||
```
|
||||
(Browse the repo's `icons/` directory if the source filename isn't an
|
||||
exact match — e.g. `ovo-new.svg` for the modern OVO mark.)
|
||||
3. **Append the slug** to `_kBundledSlugs` in
|
||||
`client_app/lib/features/payment/widgets/payment_icon.dart` so the widget
|
||||
stops falling back to the placeholder for that slug.
|
||||
4. **Cut a release.** Assets ship with the APK; new icons need a binary update.
|
||||
|
||||
## Why the explicit `_kBundledSlugs` list?
|
||||
|
||||
Flutter's asset system throws if a referenced asset is missing — there's no
|
||||
"file exists?" check at runtime without round-tripping the AssetManifest.
|
||||
Keeping the bundled-slugs list in code makes "what we ship" explicit and
|
||||
keeps the icon widget cheap.
|
||||
|
||||
## When to migrate to the pub package
|
||||
|
||||
The manual-copy approach beats `idn_finlogos` as long as we bundle fewer
|
||||
than ~50 icons (where the ~4-6 MB whole-library payload starts looking
|
||||
reasonable compared to per-file curl-and-commit overhead). If we cross that
|
||||
threshold, switch:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
idn_finlogos: ^2.3.0
|
||||
```
|
||||
|
||||
…and delete this directory's per-icon SVGs (keep `placeholder.svg` + the
|
||||
slug allowlist).
|
||||
@@ -1,25 +0,0 @@
|
||||
<svg width="80" height="26" viewBox="0 0 80 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_10653)">
|
||||
<path d="M11.7878 19.0307C11.7878 18.0314 11.7986 15.3604 11.774 15.0313C11.7955 11.0568 8.90568 8.25311 7.08002 8.47168C5.81674 8.58131 4.75801 9.09642 4.18954 10.5783C3.66266 11.9593 4.13371 13.7963 5.88545 14.2273C7.75857 14.6902 8.85233 15.0754 9.64389 15.6188C10.6138 16.284 11.4056 17.5549 11.4266 19.0323" fill="#0060AF"/>
|
||||
<path d="M12.5354 25.0722C9.23268 25.0722 5.83838 24.2588 2.44859 22.6499L2.36541 22.609L2.32563 22.5242C0.804445 19.3132 0 15.8011 0 12.365C0 8.9341 0.771444 5.57233 2.29376 2.36564L2.33558 2.28042L2.42034 2.23793C5.55606 0.752457 8.92935 0 12.4496 0C15.7288 0 19.2309 0.83767 22.5757 2.42667L22.6616 2.46487L22.7012 2.55166C24.2515 5.82187 25.0691 9.33304 25.0691 12.7122C25.0691 16.0783 24.2841 19.443 22.7319 22.7111L22.691 22.797L22.6047 22.8368C19.5169 24.2985 16.0346 25.0722 12.5354 25.0722ZM2.76232 22.2096C6.05537 23.7586 9.33959 24.5409 12.5354 24.5409C15.9241 24.5409 19.2942 23.8001 22.2941 22.396C23.7843 19.2291 24.5397 15.9698 24.5397 12.7122C24.5397 9.44153 23.7513 6.03932 22.26 2.86539C19.0139 1.33946 15.6239 0.530268 12.4496 0.530268C9.04146 0.530268 5.77486 1.25311 2.73384 2.67914C1.27414 5.78729 0.531174 9.04417 0.531174 12.365C0.531174 15.6922 1.30329 19.0958 2.76232 22.2096Z" fill="#0060AF"/>
|
||||
<path d="M11.005 19.0323C11.0111 17.7514 10.2962 16.6187 9.36175 16.01C8.5329 15.4721 7.42015 15.1186 5.62501 14.6633C5.0701 14.5214 4.48966 14.2061 4.30996 13.8042C3.83462 14.2832 3.74827 15.36 3.83191 15.9892C3.9291 16.7175 4.78011 17.9177 6.06147 17.9648C6.84399 17.9962 7.83333 17.7963 8.30777 17.6956C9.12623 17.5188 10.4212 18.0314 10.6273 19.0307" fill="#0060AF"/>
|
||||
<path d="M12.4496 2.2773C10.277 2.2773 8.40022 3.71011 8.407 6.18944C8.41378 8.27435 10.0905 9.39049 10.6888 10.1877C11.5929 11.389 12.0823 12.811 12.1331 14.9868C12.1727 16.7184 12.1706 18.4283 12.1795 19.0343H12.6591C12.6507 18.4003 12.629 16.5851 12.6539 14.9332C12.6864 12.7568 13.1934 11.389 14.098 10.1877C14.7015 9.39049 16.3768 8.27435 16.3805 6.18944C16.3886 3.71011 14.513 2.2773 12.3422 2.2773" fill="#0060AF"/>
|
||||
<path d="M13.0012 19.0307C13.0012 18.0314 12.9899 15.3604 13.0141 15.0313C12.9928 11.0568 15.8808 8.25311 17.708 8.47168C18.9713 8.58131 20.0289 9.09642 20.599 10.5783C21.1254 11.9593 20.6516 13.7963 18.9019 14.2273C17.0279 14.6902 15.9355 15.0754 15.1419 15.6188C14.1731 16.284 13.4367 17.5549 13.4141 19.0323" fill="#0060AF"/>
|
||||
<path d="M13.7833 19.0323C13.7765 17.7514 14.4912 16.6187 15.4229 16.01C16.2551 15.4721 17.3692 15.1186 19.1628 14.6633C19.7188 14.5214 20.2986 14.2061 20.4751 13.8042C20.9527 14.2832 21.0388 15.36 20.9552 15.9892C20.856 16.7175 20.0072 17.9177 18.7281 17.9648C17.9458 17.9962 16.9513 17.7963 16.4789 17.6956C15.6636 17.5188 14.3651 18.0314 14.158 19.0307" fill="#0060AF"/>
|
||||
<path d="M14.4536 23.1675L14.1755 21.1389L14.8466 21.0372C15.01 21.0148 15.2089 21.0433 15.2885 21.1457C15.3764 21.2526 15.4033 21.341 15.4203 21.4811C15.4456 21.6545 15.3954 21.855 15.2001 21.9549V21.961C15.4183 21.961 15.55 22.1176 15.5882 22.382C15.5939 22.4379 15.6104 22.5728 15.5939 22.6854C15.5496 22.9532 15.3898 23.0393 15.1201 23.0771L14.4536 23.1675ZM14.8855 22.8043C14.9651 22.7929 15.0458 22.7887 15.1088 22.7485C15.2053 22.6854 15.1965 22.5505 15.1832 22.4503C15.1495 22.2302 15.0921 22.1465 14.8586 22.1811L14.7119 22.2037L14.8048 22.8156L14.8855 22.8043ZM14.7449 21.8669C14.8337 21.8525 14.9542 21.8418 15.0044 21.7575C15.0306 21.7013 15.0643 21.6565 15.0421 21.5313C15.0148 21.3828 14.9655 21.2908 14.777 21.3265L14.6014 21.3548L14.6705 21.8746" fill="#0060AF"/>
|
||||
<path d="M17.3605 21.9474C17.3657 21.9852 17.3718 22.0272 17.3741 22.0649C17.4284 22.435 17.3605 22.7412 16.9446 22.8255C16.3298 22.944 16.2121 22.562 16.1038 22.0272L16.0462 21.7379C15.9614 21.2261 15.925 20.8387 16.5245 20.7194C16.8624 20.6577 17.0857 20.7922 17.1786 21.1285C17.193 21.1787 17.2111 21.2284 17.2179 21.2788L16.8502 21.3548C16.8077 21.2284 16.7514 21.0026 16.5859 21.0216C16.2889 21.0573 16.387 21.4269 16.4175 21.5812L16.5281 22.1366C16.5613 22.3045 16.6273 22.5728 16.8843 22.5211C17.0929 22.4793 17.0021 22.1542 16.9835 22.0211" fill="#0060AF"/>
|
||||
<path d="M17.9507 22.5613L17.8235 20.4619L18.3176 20.3112L19.3535 22.1371L18.9645 22.2535L18.719 21.7894L18.2871 21.9192L18.3429 22.4467L17.9507 22.5613ZM18.2509 21.5801L18.5631 21.4897L18.1485 20.6403" fill="#0060AF"/>
|
||||
<path d="M5.74674 20.9741C5.90135 20.4791 6.0399 20.1147 6.62984 20.2766C6.94561 20.3647 7.14135 20.504 7.13208 20.8708C7.13056 20.9524 7.1036 21.0356 7.08597 21.1163L6.71867 21.0148C6.76682 20.8123 6.79733 20.6516 6.54779 20.5747C6.25938 20.4956 6.18908 20.8453 6.1493 20.9963L5.9999 21.5467C5.95243 21.7101 5.89547 21.9802 6.1493 22.0498C6.35906 22.1063 6.48631 21.9004 6.56226 21.5998L6.30526 21.5317L6.39409 21.2133L6.99827 21.4076L6.71144 22.4684L6.43342 22.3929L6.49603 22.1687H6.48812C6.36018 22.352 6.204 22.3712 6.06725 22.345C5.46307 22.1816 5.52613 21.7867 5.67034 21.2591" fill="#0060AF"/>
|
||||
<path d="M7.99557 21.8701L7.81362 22.7331L7.40405 22.6445L7.84052 20.6294L8.53873 20.7872C8.94739 20.8753 9.0708 21.0573 9.01294 21.4318C8.97994 21.6468 8.87393 21.8785 8.61512 21.8597L8.61241 21.8562C8.83121 21.9328 8.84974 22.0431 8.81155 22.2339C8.79505 22.315 8.68135 22.8076 8.75978 22.8871L8.7625 22.9475L8.33869 22.8369C8.32106 22.7004 8.38096 22.455 8.40469 22.3191C8.42865 22.1989 8.46685 22.0293 8.34479 21.9656C8.24918 21.9147 8.21369 21.9172 8.10565 21.8924L7.99557 21.8701ZM8.06542 21.558L8.34118 21.6323C8.50866 21.6565 8.60179 21.5697 8.63479 21.3674C8.66463 21.1818 8.62597 21.1093 8.47566 21.0733L8.18002 21.0135" fill="#0060AF"/>
|
||||
<path d="M10.6225 21.0797L11.0276 21.1267L10.8531 22.5432C10.7683 22.9924 10.5947 23.1888 10.1006 23.1273C9.59791 23.0638 9.47856 22.8348 9.50908 22.3821L9.6847 20.9667L10.0929 21.0135L9.91774 22.3972C9.89921 22.5475 9.8644 22.7702 10.1318 22.7971C10.3689 22.8151 10.4227 22.6581 10.4494 22.4628" fill="#0060AF"/>
|
||||
<path d="M11.4741 23.2048L11.596 21.1791L12.3742 21.2128C12.7419 21.2309 12.8382 21.5313 12.8267 21.8179C12.8161 21.9922 12.7616 22.1868 12.6097 22.2923C12.4852 22.3821 12.3251 22.4033 12.1766 22.3961L11.923 22.382L11.8724 23.231L11.4741 23.2048ZM11.9345 22.0731L12.1407 22.0846C12.3082 22.0907 12.4192 22.0245 12.4336 21.7786C12.4418 21.5424 12.3525 21.5024 12.1355 21.4929L11.9721 21.4873" fill="#0060AF"/>
|
||||
<path d="M66.3618 4.23518L63.2688 9.8491C62.1013 8.90113 60.6758 8.20337 58.8569 8.20337C54.5526 8.20337 52.8042 11.4119 52.8042 13.672C52.8042 15.3496 53.9027 17.8248 57.7326 17.8248C59.3401 17.8248 61.6255 16.7064 62.2833 16.1976L59.2242 22.7107C57.7661 23.0016 57.2871 23.182 56.0527 23.2202C49.1979 23.4248 46.4279 19.2138 46.4299 14.9108C46.4345 9.22255 51.4919 2.30465 59.8758 2.30465C60.3896 2.30465 61.018 2.48231 61.5552 2.67918L62.0982 1.98482" fill="#0060AF"/>
|
||||
<path d="M79.1241 2.1609L80 22.5652H73.4817L73.4779 19.0657H69.0332L67.5703 22.5652H60.5012L67.8917 7.99476L66.2252 7.98391L69.3917 2.1609H79.1241ZM73.4356 8.40297L70.9226 14.3379H73.5113" fill="#0060AF"/>
|
||||
<path d="M42.3149 2.1609C45.5428 2.17898 47.3669 3.93117 47.3669 6.46227C47.3669 8.79559 45.4431 10.8611 43.3313 11.9291C45.5055 12.7283 45.6936 14.6902 45.6936 16.0783C45.6936 19.4317 42.3287 22.5652 37.9547 22.5652H28.4158L32.1367 8.19773L30.6083 8.18892L33.7327 2.1609C33.7327 2.1609 39.69 2.14282 42.3149 2.1609ZM39.1479 10.4336C39.8156 10.4336 40.9946 10.2646 41.2894 8.97233C41.6124 7.5696 40.5059 7.53185 39.9752 7.53185L38.0788 7.52354L37.4175 10.4337L39.1479 10.4336ZM36.4668 14.0393L35.5936 17.3927H37.8266C38.7051 17.3927 39.9024 16.9567 40.1958 15.8654C40.4856 14.7709 39.6486 14.0393 38.7732 14.0393" fill="#0060AF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3802_10653">
|
||||
<rect width="80" height="25.0722" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.7 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg width="80" height="24" viewBox="0 0 80 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_11102)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 8.13517L6.74901 16.6488L0 22.2413V8.13517Z" fill="#F15A23"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.71631 22.2413L7.7946 17.993L11.3046 22.2413H2.71631Z" fill="#F15A23"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 3.57962L1.76124 5.80752L9.00279 14.8564L11.3493 12.9878C11.3493 12.9878 9.40129 10.6944 8.09326 8.13515C5.53591 3.13154 7.34645 0.0162849 7.34645 0.0162849H0V3.57962Z" fill="#F15A23"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0349 16.2534L12.4247 14.2794C12.4247 14.2794 14.7071 17.2839 17.3536 17.993C20.8217 18.9223 22.3572 16.6488 22.3572 16.6488V22.2413H14.7398L16.4575 20.7562C16.4575 20.7562 15.5613 21.3537 13.3695 19.8355C12.0759 18.9395 10.0349 16.2534 10.0349 16.2534Z" fill="#F15A23"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.2548 0C10.2548 0 9.49239 1.03855 10.0351 3.57963C10.6151 6.29571 12.9219 9.17037 12.9166 9.10601C12.9166 9.10601 13.0649 6.2516 14.7399 4.57654C18.3104 1.00611 22.3574 3.13155 22.3574 3.13155V0.0162955L10.2548 0Z" fill="#F15A23"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.3573 6.8955C22.3573 6.8955 20.0993 4.96147 18.1363 4.96147C15.6251 4.96147 14.2544 7.12946 14.2544 8.7021C14.2544 10.9928 15.3539 12.2656 16.4575 13.3692C18.0434 14.9551 19.9728 16.6655 22.3573 15.2889C22.3573 12.6833 22.3573 6.8955 22.3573 6.8955Z" fill="#F15A23"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.0718 0.233002H53.3999C53.3999 0.233002 58.12 7.37988 60.4397 10.3327C62.7594 13.2855 65.2563 16.6488 65.2563 16.6488C65.2563 16.6488 65.2563 6.89552 65.2563 1.83354C65.2563 1.19368 63.5763 0.233002 63.5763 0.233002H69.7205C69.7205 0.233002 67.7044 1.06129 67.7044 1.83354C67.7044 8.13516 67.7044 23.3862 67.7044 23.3862C67.7044 23.3862 65.7699 22.3135 63.5763 19.7645C61.1115 16.9004 52.3439 5.33765 52.3439 5.33765C52.3439 5.33765 52.3439 16.2534 52.3439 20.3141C52.3439 21.0811 54.024 22.2413 54.024 22.2413H48.0718C48.0718 22.2413 49.7038 21.0717 49.7038 20.3141C49.7038 14.8564 49.7038 6.89552 49.7038 1.83354C49.7038 1.15341 48.0718 0.233002 48.0718 0.233002Z" fill="#005E6A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M73.1616 0.233002H80C80 0.233002 78.1638 1.10278 78.1638 1.83354C78.1638 6.89552 78.1638 15.2889 78.1638 20.3141C78.1638 21.1127 80 22.2413 80 22.2413H73.1616C73.1616 22.2413 74.8712 21.087 74.8712 20.3141C74.8712 14.2794 74.8712 6.89552 74.8712 1.83354C74.8712 1.13097 73.1616 0.233002 73.1616 0.233002Z" fill="#005E6A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.9526 0.233002C27.9526 0.233002 29.7255 1.11699 29.7255 1.83354C29.7255 6.89552 29.7255 14.2794 29.7255 20.3141C29.7255 21.0997 27.9526 22.2413 27.9526 22.2413C27.9526 22.2413 36.8096 22.2413 38.0835 22.2413C38.7167 22.2413 45.8083 21.0817 45.8083 15.6143C45.8083 10.147 40.2996 9.63506 40.2996 9.63506C40.2996 9.63506 43.6555 8.70212 43.6555 4.96149C43.6555 0.929499 38.7167 0.233002 38.0835 0.233002C36.7677 0.233002 27.9526 0.233002 27.9526 0.233002ZM33.3347 9.27274V2.39227C33.3347 2.39227 36.2712 2.39227 37.4503 2.39227C38.0835 2.39227 40.2996 3.13155 40.2996 5.87439C40.2996 8.13516 38.0835 9.27274 37.4503 9.27274C36.0148 9.27274 33.3347 9.27274 33.3347 9.27274ZM33.3347 11.0773C33.3347 11.0773 37.4503 11.0773 38.0835 11.0773C38.7167 11.0773 42.4525 12.0477 42.4525 15.2889C42.4525 18.5934 38.7167 19.7645 38.0835 19.7645C37.4503 19.7645 33.3347 19.7645 33.3347 19.7645V11.0773Z" fill="#005E6A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3802_11102">
|
||||
<rect width="80" height="23.3862" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1,13 +0,0 @@
|
||||
<svg width="80" height="31" viewBox="0 0 80 31" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_11089)">
|
||||
<path d="M25.636 3.73988e-05H4.61675C2.06806 3.73988e-05 0 2.11803 0 4.73408V25.5446C0 28.1366 2.02716 30.2414 4.54757 30.2787L25.621 30.2793C28.1697 30.2793 30.2426 28.1607 30.2426 25.5446L30.2528 4.73408C30.2528 2.11803 28.1841 3.73988e-05 25.636 3.73988e-05ZM7.11491 27.8533L5.96779 27.8672C4.57885 27.8672 3.4576 26.7236 3.4576 25.3185L3.44918 25.1158V5.89805L3.4576 4.86221C3.51354 3.50275 4.54517 2.40436 5.89861 2.40436H8.22834C10.3439 2.40436 12.0547 4.24203 12.0547 6.40634C12.0547 7.48128 11.6336 8.45155 10.9527 9.16075L5.00774 15.1905L10.5821 20.8689C11.298 21.6112 11.7311 22.5851 11.7311 23.6492C11.7311 25.9705 9.6684 27.8533 7.11491 27.8533V27.8533ZM24.2525 27.8359L13.8587 27.8329C13.8587 27.8329 15.0605 25.2331 15.0605 23.6264C15.0605 21.6672 14.4055 19.9137 13.3799 18.7564L9.82661 15.1315L13.4502 11.3864C14.5348 10.3674 15.2506 8.56463 15.2506 6.51401C15.2506 4.88928 14.8019 3.44982 14.0752 2.40436H16.595C18.7088 2.40436 20.4214 4.24203 20.4214 6.40634C20.4214 7.48128 20.0021 8.45155 19.323 9.16075L13.4881 15.1291L25.603 27.484C25.2168 27.7258 24.7343 27.8359 24.2525 27.8359V27.8359ZM26.788 23.9771L18.1049 15.1273L22.2398 10.9262C23.0825 9.87056 23.6191 8.28552 23.6191 6.51401C23.6191 4.87965 23.1619 3.4023 22.4323 2.35744L24.2808 2.41518C25.6673 2.41518 26.7952 3.55509 26.7952 4.96447L26.788 23.9771Z" fill="#00529C"/>
|
||||
<path d="M48.8834 13.7179C49.0525 13.5489 49.2522 13.3311 49.4826 13.0647C49.7123 12.7982 49.9301 12.4836 50.1358 12.1203C50.3415 11.7569 50.5172 11.3521 50.6622 10.904C50.8077 10.4564 50.8805 9.97821 50.8805 9.46992C50.8805 8.47799 50.7109 7.55224 50.3722 6.69265C50.033 5.83366 49.5126 5.08295 48.8107 4.44112C48.1087 3.80049 47.2316 3.29761 46.1784 2.93428C45.1257 2.57156 43.8967 2.3899 42.4934 2.3899H42.4549H38.4198H37.3695H33.2346V7.01808V23.0405V27.8052H43.4011C44.8772 27.8052 46.1657 27.6055 47.2677 27.2061C48.3685 26.8066 49.2829 26.2562 50.0089 25.5542C50.7349 24.8523 51.2733 24.0354 51.6246 23.1036C51.9753 22.1718 52.151 21.1733 52.151 20.108C52.151 18.68 51.8243 17.4095 51.1711 16.2955C50.5172 15.1827 49.7545 14.3231 48.8834 13.7179ZM44.3882 7.04695C44.7305 7.17748 45.0162 7.34711 45.2394 7.56066C45.8174 8.11527 46.1068 8.77815 46.1068 9.54932C46.1068 10.2489 45.9859 10.827 45.7453 11.2847C45.504 11.7431 45.2628 12.0926 45.0222 12.3332H37.9548V6.74679H42.3478C43.1713 6.74679 43.8492 6.84844 44.3882 7.04695V7.04695ZM43.6549 16.7316C44.9344 16.7316 45.8824 17.063 46.4996 17.7247C47.08 18.3762 47.3724 19.1449 47.3724 20.0346C47.3724 20.9513 47.0409 21.7339 46.3781 22.3848C45.7146 23.0356 44.6246 23.3617 43.1057 23.3617H42.5385H37.9548V16.7316H43.6549Z" fill="#00529C"/>
|
||||
<path d="M69.3872 16.8585C70.1493 16.3382 70.7851 15.7331 71.2934 15.0431C71.8017 14.3531 72.1765 13.591 72.4189 12.7561C72.6607 11.9206 72.7822 11.0676 72.7822 10.196C72.7822 9.05847 72.5939 8.0118 72.2192 7.05537C71.8432 6.09954 71.2808 5.27665 70.5307 4.58669C69.78 3.89673 68.8422 3.35836 67.7167 2.97098C66.5913 2.58419 65.2902 2.3899 63.814 2.3899H63.6811H53.902H53.8105V27.8052H58.6222V18.3654H61.305L67.9405 27.8052H73.5805L66.7188 18.0387C67.7348 17.7728 68.6244 17.3794 69.3872 16.8585ZM63.8501 6.74679C64.1527 6.74679 64.4384 6.76664 64.7103 6.80092C65.5681 6.93025 66.2689 7.23643 66.809 7.72307C67.5441 8.38656 67.9116 9.19983 67.9116 10.1635C67.9116 10.6465 67.8328 11.1163 67.6764 11.5741C67.5194 12.0324 67.2728 12.4361 66.9353 12.785C66.5973 13.135 66.1636 13.4178 65.6336 13.6349C65.1031 13.8521 64.4643 13.9603 63.7172 13.9603H58.6222V6.74679H63.8501Z" fill="#00529C"/>
|
||||
<path d="M75.2797 2.3899V2.39712C75.231 2.39591 75.1835 2.3899 75.1348 2.3899H75.0939V2.3917V11.7076V18.3828V27.8052H80V2.3899H75.2797Z" fill="#00529C"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3802_11089">
|
||||
<rect width="80" height="30.2793" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,15 +0,0 @@
|
||||
<svg width="80" height="23" viewBox="0 0 80 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_18349)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.623 17.3487C33.1435 17.4576 34.9152 16.2764 35.5393 15.6021C37.7089 13.2544 37.7613 9.85968 35.577 7.40105C34.7644 6.48586 32.8545 5.47435 31.6461 5.61675H27.2712V17.334L31.6335 17.3487H31.623ZM29.7382 14.8105V8.14031C30.423 8.09539 31.11 8.09539 31.7948 8.14031C32.3336 8.20279 32.8475 8.40174 33.288 8.71832C35.8011 10.4356 34.7539 14.5298 31.7529 14.8042C31.0822 14.8513 30.4092 14.8534 29.7382 14.8105Z" fill="#008CEB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.0293 17.3696H72.5236V14.844H77.4723L77.4869 17.3571H79.9812V13.1937C79.9812 11.9372 80.0859 10.3707 79.7717 9.23351C79.4864 8.19245 78.8684 7.2733 78.012 6.61626C77.1555 5.95923 76.1077 5.60037 75.0282 5.59444C73.9488 5.58851 72.8971 5.93582 72.0335 6.58341C71.1698 7.23099 70.5418 8.1433 70.245 9.18115C69.9099 10.3037 70.023 11.9141 70.023 13.1602C70.023 14.5675 70.0084 15.9832 70.023 17.3906L70.0293 17.3696ZM72.5257 12.3037C72.511 11.2565 72.4 10.1361 72.8922 9.32984C73.1082 8.95863 73.4187 8.65127 73.792 8.43902C74.1654 8.22676 74.5883 8.1172 75.0178 8.12147C75.4451 8.12462 75.8642 8.23858 76.2342 8.45223C76.6042 8.66587 76.9125 8.97187 77.1288 9.34031C77.6147 10.178 77.4995 11.2251 77.4869 12.3058L72.5257 12.3037Z" fill="#008CEB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.5058 17.3675H43.9916C43.9916 16.8398 43.9246 15.2251 44.0147 14.8419H48.9445L48.9613 17.3654H51.4513C51.4387 15.9665 51.4513 14.5696 51.4513 13.177C51.4513 11.9832 51.5623 10.2743 51.2419 9.21466C50.9548 8.17496 50.3358 7.25754 49.4792 6.60203C48.6227 5.94652 47.5754 5.58882 46.4968 5.58337C45.4182 5.57791 44.3673 5.92501 43.5042 6.57182C42.641 7.21864 42.0128 8.12975 41.7152 9.16649C41.3822 10.2555 41.4953 11.9204 41.4953 13.133C41.4953 14.5319 41.4639 15.9665 41.4953 17.3613L41.5058 17.3675ZM43.9937 12.3037C43.977 11.2565 43.8702 10.1215 44.3623 9.33194C44.5783 8.96169 44.8875 8.65452 45.2592 8.44103C45.6309 8.22754 46.052 8.1152 46.4806 8.1152C46.9093 8.1152 47.3304 8.22754 47.7021 8.44103C48.0738 8.65452 48.383 8.96169 48.599 9.33194C49.0869 10.1696 48.9696 11.2335 48.9613 12.2995L43.9937 12.3037Z" fill="#008CEB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.8262 10.1927L55.7906 11.9372C55.7906 13.4031 55.7194 16.1403 55.7906 17.3319H58.2283C58.2848 16.4942 58.1403 10.5445 58.2932 10.3539C58.2932 9.19791 59.5016 8.12147 60.7204 8.11728C61.354 8.11346 61.964 8.35689 62.4209 8.79581C62.7309 9.07853 63.2063 9.72356 63.1979 10.3749C63.311 10.4398 63.2209 16.6576 63.2565 17.3466H65.6963L65.6775 10.2367C65.7382 9.67958 65.4031 8.81885 65.1874 8.38325C64.7874 7.55326 64.163 6.85185 63.3849 6.35847C62.6067 5.86508 61.706 5.59942 60.7847 5.59157C59.8634 5.58372 58.9583 5.834 58.1719 6.31407C57.3855 6.79413 56.7492 7.4848 56.3351 8.30785C56.1257 8.73717 55.7738 9.62094 55.8262 10.1927Z" fill="#008CEB"/>
|
||||
<path d="M11.4366 22.8733C17.7529 22.8733 22.8733 17.7529 22.8733 11.4366C22.8733 5.12036 17.7529 0 11.4366 0C5.12036 0 0 5.12036 0 11.4366C0 17.7529 5.12036 22.8733 11.4366 22.8733Z" fill="#008CEB"/>
|
||||
<path d="M18.1005 11.4848V14.7037C18.1005 14.9131 18.0105 14.9571 17.8346 14.8544C17.5992 14.7132 17.3543 14.5887 17.1016 14.4817C16.3383 14.1776 15.5115 14.0675 14.6953 14.1613C13.8044 14.2768 12.93 14.4961 12.0901 14.8147C11.2524 15.0974 10.4314 15.4031 9.57696 15.6398C8.86925 15.85 8.13275 15.9468 7.39476 15.9267C6.5769 15.895 5.78205 15.6471 5.0911 15.2084C5.00429 15.1561 4.93306 15.0815 4.88481 14.9924C4.83657 14.9033 4.81307 14.8028 4.81675 14.7016C4.81675 12.5934 4.81675 10.4852 4.81675 8.37696C4.81675 8.27435 4.81675 8.15288 4.9089 8.10052C5.00105 8.04817 5.09529 8.13403 5.17487 8.18639C5.92517 8.69371 6.81381 8.95701 7.71937 8.94031C8.36688 8.91375 9.00724 8.79456 9.62094 8.58639C10.4921 8.31623 11.334 7.95812 12.1906 7.65445C12.8573 7.39821 13.541 7.18829 14.2366 7.02618C14.8337 6.88636 15.4532 6.87168 16.0562 6.98305C16.6592 7.09442 17.2326 7.32944 17.7403 7.6733C17.8567 7.74461 17.952 7.84565 18.0164 7.96601C18.0808 8.08636 18.112 8.22171 18.1068 8.35812C18.1068 9.40524 18.1068 10.4524 18.1068 11.4995L18.1005 11.4848Z" fill="#FEFEFE"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3802_18349">
|
||||
<rect width="79.9979" height="22.8733" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,13 +0,0 @@
|
||||
<svg width="80" height="18" viewBox="0 0 80 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_18151)">
|
||||
<path d="M8.73161 17.4632C13.554 17.4632 17.4632 13.554 17.4632 8.73161C17.4632 3.90928 13.554 0 8.73161 0C3.90928 0 0 3.90928 0 8.73161C0 13.554 3.90928 17.4632 8.73161 17.4632Z" fill="#00AED6"/>
|
||||
<path d="M8.73161 17.4632C13.554 17.4632 17.4632 13.554 17.4632 8.73161C17.4632 3.90928 13.554 0 8.73161 0C3.90928 0 0 3.90928 0 8.73161C0 13.554 3.90928 17.4632 8.73161 17.4632Z" fill="#00AED6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.8174 8.45967C13.7531 7.3509 12.8095 6.49976 11.7 6.54963H6.18455C5.98362 6.54963 5.82073 6.38674 5.82073 6.18581C5.82073 5.98488 5.98362 5.82199 6.18455 5.82199H11.7727C11.7425 5.06711 11.2277 4.4184 10.4994 4.21756C8.87117 3.93015 7.20521 3.93015 5.577 4.21756C4.65145 4.44555 3.95909 5.2158 3.83068 6.16034C3.57239 7.87395 3.57239 9.61658 3.83068 11.3302C4.02732 12.3276 4.81888 13.1004 5.82076 13.273C7.84189 13.524 9.8863 13.524 11.9074 13.273C12.8062 13.089 13.4863 12.3503 13.5956 11.4393C13.7765 10.457 13.8509 9.45798 13.8174 8.45967ZM12.0056 9.68209V10.0059C12.0056 10.2068 11.8427 10.3697 11.6418 10.3697C11.4409 10.3697 11.278 10.2068 11.278 10.0059V9.68209C11.1617 9.58018 11.0954 9.43286 11.0961 9.27826C11.0961 8.97686 11.3404 8.73253 11.6418 8.73253C11.9432 8.73253 12.1875 8.97686 12.1875 9.27826C12.1882 9.43286 12.1219 9.58018 12.0056 9.68209Z" fill="white"/>
|
||||
<path d="M24.3658 13.0782C25.1069 14.1355 26.3393 14.7378 27.6288 14.6727C29.1494 14.6727 30.2679 13.7014 30.2679 12.3811V11.6848H30.231C29.3978 12.499 28.2604 12.9249 27.0973 12.858C25.2599 12.9107 23.5374 11.9665 22.5936 10.3892C21.6497 8.81182 21.6318 6.84759 22.5468 5.25335C23.4618 3.65911 25.1669 2.68372 27.0049 2.70304C28.1901 2.627 29.3551 3.0373 30.231 3.83926H30.2679V2.88638H32.8715V12.3449C32.8715 15.0941 30.69 17.0006 27.6288 17.0006C25.6237 17.11 23.6877 16.2511 22.4231 14.6912L24.3658 13.0782ZM30.1585 7.19392C30.1585 6.09394 28.9121 5.08563 27.5193 5.08563C25.7585 5.08563 24.5861 6.14936 24.5861 7.74391C24.5339 8.49941 24.8178 9.23942 25.362 9.76607C25.9062 10.2927 26.6551 10.5523 27.4085 10.4754C28.9306 10.4754 30.1585 9.52178 30.1585 8.33014V7.19392ZM39.6902 2.61139C42.8622 2.61139 45.1716 4.86606 45.1716 7.74391C45.1716 10.6218 42.8622 12.8764 39.6902 12.8764C37.7763 13.0019 35.9515 12.052 34.9565 10.4123C33.9614 8.7725 33.9614 6.71532 34.9565 5.07556C35.9515 3.43581 37.7763 2.48592 39.6902 2.61139ZM39.6902 4.99396C38.1741 5.0043 36.9525 6.23985 36.9593 7.75594C36.9662 9.27203 38.1989 10.4965 39.715 10.4932C41.2311 10.4899 42.4584 9.26002 42.4586 7.74391C42.493 7.00132 42.2117 6.27883 41.6843 5.75493C41.1569 5.23104 40.4325 4.95462 39.6902 4.99396ZM46.6382 2.88638H49.2418V3.7476H49.2788C50.1295 2.96525 51.258 2.55607 52.4124 2.61139C55.2074 2.66182 57.4483 4.93938 57.4534 7.73485C57.4584 10.5303 55.2257 12.8159 52.4309 12.8764C51.3276 12.9019 50.2505 12.5384 49.3882 11.8496H49.3512V16.7256H46.6382V2.88638ZM51.9918 5.01241C50.5606 5.01241 49.3512 6.02075 49.3512 7.12073V8.34861C49.3512 9.52178 50.5251 10.4931 52.0088 10.4931C53.5223 10.4884 54.7453 9.25772 54.7407 7.74426C54.7359 6.23081 53.5052 5.00772 51.9918 5.01241ZM62.568 6.80878C64.3459 6.57074 64.8774 6.31422 64.8774 5.81894C64.8774 5.17729 64.1995 4.79286 63.155 4.79286C62.0295 4.69756 61.0023 5.43641 60.7347 6.53379L58.1681 6.00227C58.5348 4.0226 60.5699 2.61139 63.0811 2.61139C65.922 2.61139 67.6828 4.05955 67.6828 6.42365V12.6014H65.2441V11.5384H65.2071C64.3805 12.4698 63.1679 12.9643 61.9257 12.8764C59.7811 12.8764 58.2974 11.7033 58.2974 9.9801C58.2975 8.16528 59.5069 7.23087 62.568 6.80878ZM65.0977 7.83557H65.0608C64.822 8.18376 64.309 8.38556 62.9901 8.6236C61.3942 8.91707 60.8271 9.22831 60.8271 9.79677C60.8271 10.3837 61.3032 10.6402 62.3293 10.6402C63.8883 10.6402 65.0977 9.92538 65.0977 8.99026V7.83557ZM72.5943 12.0699L68.1035 2.88638H71.0922L74.0425 9.22831H74.0794L76.9942 2.88638H80L73.2736 16.7256H70.2849L72.5943 12.0699Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3802_18151">
|
||||
<rect width="80" height="17.4632" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,23 +0,0 @@
|
||||
<svg width="80" height="25" viewBox="0 0 80 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_8197)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.8989 0.896292C57.3589 1.81741 53.7265 3.96929 52.3705 4.77064C51.5441 5.20551 49.6301 5.39539 48.5475 3.88763C48.5281 3.86041 47.1077 2.02711 47.0497 1.95808C47.009 1.9093 46.0907 0.502368 44.0445 0.458381C43.7422 0.450977 42.239 0.442702 40.7724 1.30372C38.8229 2.45413 34.2891 5.13147 34.2891 5.13147C34.2891 5.13147 34.2878 5.13386 34.2855 5.13386C32.4218 6.23528 30.9697 7.09282 30.9697 7.09282L32.6854 9.26931C33.4879 10.2986 35.2978 11.095 36.8674 10.1643C36.8674 10.1643 42.6668 6.69541 42.6881 6.68539C45.1962 5.26452 47.1315 5.26452 48.416 5.79324C49.569 6.29647 50.572 7.55033 50.572 7.55033C50.572 7.55033 51.8829 9.22837 52.1142 9.52191C52.86 10.4702 54.0946 10.0977 54.0946 10.0977C54.0946 10.0977 54.5521 10.0441 55.2417 9.6212C55.2417 9.6212 60.8591 6.25684 60.8627 6.25575C62.6464 5.1748 64.2824 4.97294 65.1185 5.05242C67.7373 5.29827 68.5508 7.18798 69.6863 8.50628C70.355 9.28172 70.9584 9.72203 71.8811 9.69982C72.4874 9.68653 73.1724 9.30676 73.2734 9.23817L80.0002 5.22837C80.0002 5.22837 79.3099 4.15461 77.8964 2.48397C76.6313 0.993412 75.2875 1.66781 74.2162 2.21591C73.7661 2.44586 72.1352 3.56644 70.5195 4.34819C69.3682 4.905 67.7145 4.73754 66.8082 3.58081C66.754 3.51048 65.2878 1.67695 65.1337 1.45136C64.5419 0.68572 63.3873 0 61.9075 0C61.0078 0 59.9868 0.25347 58.8989 0.896292Z" fill="url(#paint0_linear_3802_8197)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.0843273 16.5732C0.0843273 15.376 0.0632455 14.3713 0 13.4754H2.1942L2.29708 15.0145H2.35864C2.85596 14.2012 3.76543 13.2394 5.46188 13.2394C6.78603 13.2394 7.82031 14.0104 8.25396 15.1639H8.29655C8.64882 14.5871 9.06118 14.16 9.53658 13.8597C10.0957 13.4543 10.7372 13.2394 11.5647 13.2394C13.2398 13.2394 14.935 14.4144 14.935 17.7483V23.8801H12.453V18.1335C12.453 16.4029 11.8748 15.376 10.655 15.376C9.78471 15.376 9.14277 16.0182 8.87566 16.7664C8.81284 17.022 8.75086 17.3428 8.75086 17.6398V23.8801H6.26805V17.8547C6.26805 16.4029 5.71044 15.376 4.53112 15.376C3.57949 15.376 2.93902 16.1464 2.71091 16.8713C2.60677 17.1294 2.56503 17.4277 2.56503 17.7263V23.8801H0.0843273V16.5732Z" fill="#003A70"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6052 21.3809C24.6052 22.3205 24.6465 23.2377 24.75 23.8806H22.4532L22.2883 22.7271H22.2255C21.6053 23.5398 20.5503 24.1157 19.2473 24.1157C17.2196 24.1157 16.0825 22.5999 16.0825 21.0179C16.0825 18.3889 18.3363 17.0654 22.06 17.0865V16.9149C22.06 16.2309 21.7908 15.0993 20.0121 15.0993C19.0187 15.0993 17.984 15.42 17.3018 15.8679L16.8047 14.1601C17.5508 13.6891 18.8551 13.2398 20.4466 13.2398C23.6749 13.2398 24.6052 15.3564 24.6052 17.6202V21.3809ZM22.122 18.7959C20.3226 18.7528 18.6062 19.1587 18.6062 20.7401C18.6062 21.7646 19.2473 22.2356 20.0543 22.2356C21.0666 22.2356 21.8125 21.5516 22.0393 20.8041C22.1009 20.6118 22.122 20.3973 22.122 20.227V18.7959Z" fill="#003A70"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.3642 16.5732C26.3642 15.376 26.3415 14.3713 26.2812 13.4754H28.5133L28.6381 15.035H28.6993C29.1342 14.2241 30.2304 13.2394 31.9056 13.2394C33.6645 13.2394 35.4847 14.4144 35.4847 17.7056V23.8801H32.9407V18.0054C32.9407 16.5088 32.4029 15.376 31.0168 15.376C30.0032 15.376 29.3001 16.124 29.0305 16.9149C28.9491 17.1505 28.9276 17.4717 28.9276 17.7687V23.8801H26.3642V16.5732Z" fill="#003A70"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M46.5156 9.2947V20.9524C46.5156 22.0205 46.5563 23.1763 46.5978 23.8806H44.3223L44.2204 22.2356H44.1783C43.5781 23.39 42.3557 24.1158 40.8887 24.1158C38.4902 24.1158 36.5865 22.0009 36.5865 18.7959C36.5654 15.3129 38.6753 13.2398 41.0953 13.2398C42.4808 13.2398 43.4746 13.8389 43.9289 14.6087H43.9708V9.2947H46.5156ZM43.9708 17.8115C43.9708 17.6 43.9495 17.3424 43.9097 17.1294C43.6818 16.1038 42.8746 15.27 41.7161 15.27C40.0821 15.27 39.1718 16.7664 39.1718 18.7108C39.1718 20.6118 40.0821 22.0009 41.6955 22.0009C42.7293 22.0009 43.6401 21.2747 43.888 20.1426C43.9495 19.9065 43.9708 19.6504 43.9708 19.3717V17.8115Z" fill="#003A70"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.3134 23.88H50.8809V13.476H48.3134V23.88Z" fill="#003A70"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M52.679 16.8295C52.679 15.4199 52.6575 14.4143 52.5957 13.4751H54.8095L54.8904 15.4615H54.9762C55.4719 13.9873 56.6512 13.4751 57.7264 13.4751C57.9741 13.4751 58.1187 13.4314 58.3262 13.4751V15.7834C58.1187 15.7407 57.8917 15.698 57.5813 15.698C56.3611 15.698 55.533 16.5092 55.3058 17.6835C55.2655 17.9187 55.2238 18.1977 55.2238 18.4982V23.8805H52.679V16.8295Z" fill="#003A70"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M59.5053 23.88H62.069V13.476H59.5053V23.88Z" fill="#003A70"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3802_8197" x1="0" y1="24.1158" x2="80.0002" y2="24.1158" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFCA06"/>
|
||||
<stop offset="0.330844" stop-color="#FBAA18"/>
|
||||
<stop offset="0.694742" stop-color="#FFC907"/>
|
||||
<stop offset="1" stop-color="#FAA619"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_3802_8197">
|
||||
<rect width="80.0002" height="24.1158" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.1 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="64" height="65" viewBox="0 0 64 65" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_16524)">
|
||||
<path d="M31.9574 64.9375C25.7648 64.9375 20.2539 63.574 15.4248 60.8469C10.5957 58.0631 6.81758 54.2282 4.09055 49.3423C1.36352 44.3995 0 38.8034 0 32.554C0 26.3045 1.36352 20.7084 4.09055 15.7657C6.87439 10.8229 10.6809 6.95962 15.51 4.17577C20.3959 1.39192 25.9352 0 32.1278 0C38.2637 0 43.7461 1.39192 48.5752 4.17577C53.4612 6.9028 57.2392 10.7377 59.9095 15.6804C62.6365 20.6232 64 26.2477 64 32.554C64 38.8034 62.6365 44.3995 59.9095 49.3423C57.1824 54.2282 53.3759 58.0631 48.49 60.8469C43.6609 63.574 38.15 64.9375 31.9574 64.9375ZM31.9574 59.9095C37.241 59.9095 41.8997 58.7732 45.9334 56.5007C49.9672 54.2282 53.0919 51.0182 55.3076 46.8709C57.5801 42.7235 58.7164 37.9512 58.7164 32.554C58.7164 27.1567 57.6085 22.3844 55.3928 18.237C53.1771 14.0329 50.0524 10.7945 46.0186 8.52198C41.9849 6.19264 37.3546 5.02797 32.1278 5.02797C26.901 5.02797 22.2423 6.19264 18.1518 8.52198C14.0613 10.7945 10.8797 14.0329 8.60719 18.237C6.39148 22.3844 5.28362 27.1567 5.28362 32.554C5.28362 37.9512 6.39148 42.7235 8.60719 46.8709C10.8229 51.0182 13.9476 54.2282 17.9814 56.5007C22.0151 58.7732 26.6738 59.9095 31.9574 59.9095ZM31.9574 57.5234C27.1851 57.5234 22.9525 56.5007 19.2597 54.4554C15.5668 52.3534 12.6977 49.4275 10.6525 45.6778C8.664 41.8713 7.66977 37.4967 7.66977 32.554C7.66977 27.6112 8.69241 23.2366 10.7377 19.4301C12.783 15.6236 15.6236 12.6693 19.2597 10.5673C22.9525 8.46517 27.1851 7.41412 31.9574 7.41412C36.7297 7.41412 40.9623 8.46517 44.6551 10.5673C48.348 12.6693 51.217 15.652 53.2623 19.5153C55.3076 23.3218 56.3302 27.668 56.3302 32.554C56.3302 37.4399 55.3076 41.7861 53.2623 45.5926C51.217 49.3423 48.348 52.2681 44.6551 54.3702C40.9623 56.4723 36.7297 57.5234 31.9574 57.5234ZM31.9574 52.4954C35.7071 52.4954 39.0306 51.6716 41.9281 50.024C44.8256 48.3196 47.0697 45.9619 48.6605 42.9508C50.2512 39.8829 51.0466 36.4173 51.0466 32.554C51.0466 28.6338 50.2228 25.1682 48.5752 22.1571C46.9845 19.0892 44.7404 16.7031 41.8429 14.9987C38.9454 13.2943 35.6502 12.4421 31.9574 12.4421C28.2645 12.4421 24.9694 13.2943 22.0719 14.9987C19.1744 16.7031 16.9303 19.0892 15.3395 22.1571C13.7488 25.1682 12.9534 28.6338 12.9534 32.554C12.9534 36.4173 13.7488 39.8829 15.3395 42.9508C16.9303 45.9619 19.1744 48.3196 22.0719 50.024C24.9694 51.6716 28.2645 52.4954 31.9574 52.4954Z" fill="#5827D4"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3802_16524">
|
||||
<rect width="64" height="64.9375" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,33 +0,0 @@
|
||||
<svg width="80" height="43" viewBox="0 0 80 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_9601)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.6181 41.6449C51.6264 41.4925 51.6352 41.2482 51.6431 40.9134V34.4931L51.6223 33.8701C51.6172 33.7376 51.6108 33.6476 51.6043 33.6005C51.5974 33.5506 51.5867 33.5077 51.572 33.4679C51.5239 33.3096 51.4173 33.1978 51.257 33.1309C51.1093 33.0685 50.8909 33.0385 50.6073 33.0362V32.8178H54.2737C54.8066 32.8187 55.2882 32.8533 55.7181 32.9226C56.1476 32.9914 56.4934 33.0865 56.758 33.2075C57.5246 33.5709 57.9032 34.1828 57.8991 35.0532C57.9078 36.2972 57.0896 37.0919 55.3958 37.2964L55.1118 37.3421C55.4928 37.356 56.0081 37.483 56.2478 37.5384C56.4856 37.5933 56.7188 37.6806 56.9474 37.7998C57.8464 38.2514 58.2892 38.9736 58.2805 39.9723C58.2851 41.1854 57.6936 41.9741 56.493 42.3467C56.2307 42.4215 55.9019 42.4806 55.5043 42.5236C55.1054 42.5665 54.6722 42.5887 54.2054 42.5896H50.6073V42.3708C51.0308 42.3657 51.3221 42.301 51.451 42.1491C51.505 42.0974 51.5433 42.0406 51.5687 41.9755C51.5946 41.9094 51.6098 41.7968 51.6181 41.6449ZM52.7476 33.1179V37.1925H54.1782C55.8419 37.1828 56.7035 36.4916 56.7128 35.1077C56.7243 33.7644 55.816 33.1087 54.0553 33.1179H52.7476ZM56.9991 39.8634C56.9898 38.9135 56.6463 38.2421 55.9615 37.8667C55.7537 37.7605 55.4896 37.6788 55.1723 37.621C54.8556 37.5638 54.506 37.5347 54.1237 37.5333H52.7476V42.289H54.1237C55.1206 42.2936 55.8493 42.1075 56.2981 41.7174C56.5258 41.5303 56.6985 41.2764 56.8185 40.9578C56.9381 40.6391 56.9977 40.2734 56.9991 39.8634Z" fill="#3A3A3A"/>
|
||||
<path d="M63.528 42.3108C63.4536 42.1635 63.4014 41.9884 63.3788 41.541L63.3723 41.4085L63.2841 41.5077C63.1793 41.6264 63.0764 41.7289 62.9757 41.8162C62.8746 41.9044 62.7573 41.9944 62.6257 42.0859C62.3361 42.2983 62.0572 42.4479 61.7899 42.5352C61.5225 42.622 61.221 42.6645 60.8853 42.6617C60.3007 42.664 59.8519 42.5255 59.5309 42.2526C59.3513 42.091 59.2105 41.8882 59.1075 41.6421C59.0045 41.3965 58.9519 41.1323 58.9496 40.8488C58.9565 39.8034 59.5983 39.1287 60.9111 38.83C61.2584 38.7496 61.6255 38.6836 62.0115 38.6319C62.3989 38.5802 62.8376 38.5377 63.3285 38.5044L63.3788 37.5924C63.3797 37.2808 63.3645 37.0282 63.334 36.8356C63.3035 36.6408 63.2532 36.4708 63.1826 36.3272C62.9877 35.9476 62.5906 35.7657 62.0166 35.7657C61.0958 35.7611 60.5967 36.1993 60.545 37.0711C60.5394 37.2096 60.5265 37.3071 60.5085 37.3662C60.4909 37.4216 60.4609 37.4627 60.4184 37.4927C60.327 37.5541 60.1811 37.5897 59.9715 37.592C59.5203 37.5989 59.3513 37.4645 59.3587 37.2387C59.3615 37.0023 59.44 36.7737 59.5974 36.5535C59.7549 36.3323 59.9742 36.1402 60.2577 35.9763C60.7675 35.6937 61.4205 35.5496 62.2212 35.5473C63.0971 35.5496 63.6845 35.7482 64.0054 36.1273C64.1352 36.287 64.2317 36.4842 64.2945 36.7229C64.3568 36.9617 64.3887 37.2641 64.3882 37.6322L64.3868 37.7102L64.361 39.0082L64.3471 40.303L64.3337 40.9301C64.3347 41.2251 64.3471 41.45 64.3674 41.7017C64.3878 41.9469 64.4755 42.2202 64.6717 42.2927C64.8763 42.3685 65.073 42.3985 65.4475 42.4008C65.7227 42.3953 65.9347 42.3639 66.0783 42.3029C66.2422 42.2332 66.3429 42.0854 66.3863 41.8758C66.4288 41.6712 66.4472 41.36 66.4426 40.943V37.2886C66.4518 36.7913 66.3941 36.4413 66.2524 36.2584C66.1138 36.0797 65.8562 36.0026 65.502 36.0063V35.8124L67.4516 35.6701V37.223L67.5532 37.042C67.8418 36.5281 68.1691 36.1647 68.5335 35.9463C68.8978 35.7278 69.3545 35.6198 69.9045 35.6253C70.731 35.6143 71.3068 35.8913 71.6439 36.445C71.7672 36.6301 71.8573 36.8536 71.9108 37.115C71.9644 37.3768 71.9912 37.7116 71.9898 38.1193V40.943C71.9847 41.3641 72.0022 41.6777 72.0429 41.8813C72.0844 42.091 72.1842 42.2383 72.3486 42.3061C72.4843 42.362 72.8011 42.3925 73.0712 42.3999C73.3243 42.392 73.5201 42.3611 73.654 42.3024C73.8137 42.2327 73.9103 42.0845 73.9514 41.8748C73.9911 41.6712 74.0081 41.3595 74.0031 40.943V34.3038C74.0081 33.9233 73.9925 33.6411 73.9541 33.4592C73.9144 33.2694 73.8165 33.1387 73.6563 33.0824C73.5173 33.0335 73.309 33.0104 73.0357 33.0094V32.815L75.0633 32.7116C75.0379 33.1618 75.0185 33.6222 75.0056 34.0927C74.9917 34.5841 74.9852 35.013 74.9852 35.3801V39.8403L77.2193 37.3925C77.5351 37.0425 77.7078 36.7483 77.7101 36.5128C77.7263 36.185 77.3952 36.0368 76.8517 36.0095V35.816H79.7548V36.0128C79.5803 36.0331 79.4283 36.0649 79.2995 36.1093C79.1591 36.1568 79.0151 36.2261 78.8682 36.3148C78.7191 36.4048 78.5944 36.4874 78.4933 36.5627C78.3903 36.6398 78.2688 36.7493 78.1289 36.8915C77.989 37.0333 77.7868 37.2484 77.5208 37.5375L76.7833 38.3313L78.7135 41.3674C78.9338 41.7271 79.1365 41.9815 79.3226 42.126C79.5004 42.2637 79.7271 42.3523 80 42.3943V42.5929H78.2347C78.1862 42.4941 78.1197 42.3662 78.0347 42.2092L77.754 41.6971L77.5393 41.3115L76.1106 38.9833L74.988 40.2393L74.9852 40.943C74.9802 41.3595 74.9972 41.6712 75.0374 41.8753C75.079 42.0849 75.1769 42.2332 75.339 42.3029C75.4807 42.3634 75.6917 42.3953 75.966 42.4008V42.5929H69.9996V42.4003C70.4036 42.3893 70.6811 42.3232 70.7924 42.1759C70.8372 42.1187 70.8709 42.0535 70.8945 41.9806C70.9185 41.9072 70.9328 41.8125 70.9397 41.6971C70.9476 41.6056 70.9559 41.474 70.9651 41.3009C70.9748 41.1272 70.9799 41.0077 70.9808 40.9439V38.2015C70.9831 37.525 70.9106 37.0245 70.7573 36.7063C70.5251 36.2058 70.1049 35.955 69.5092 35.9527C68.8452 35.9504 68.3155 36.2667 67.923 36.8915C67.7753 37.1275 67.6607 37.3962 67.5781 37.6959C67.495 37.9965 67.453 38.292 67.4516 38.5829V40.943C67.4465 41.3641 67.464 41.6777 67.5047 41.8813C67.5462 42.091 67.6455 42.2383 67.8099 42.3061C67.9535 42.3652 68.1673 42.3957 68.4462 42.4008V42.5929L65.5117 42.5924C65.2513 42.6012 64.7558 42.6146 64.5101 42.6114C64.2525 42.6077 64.1283 42.5804 63.8932 42.5629C63.6845 42.5019 63.5968 42.4474 63.528 42.3108ZM60.068 40.8082C60.073 41.2524 60.1811 41.6135 60.3967 41.8859C60.6128 42.1598 60.9056 42.3001 61.2667 42.3071C61.6315 42.302 61.9764 42.2069 62.2992 42.0221C62.6211 41.8374 62.8746 41.5913 63.0583 41.2852C63.1706 41.0898 63.2523 40.8386 63.3035 40.5343C63.3543 40.2314 63.3797 39.8546 63.3788 39.4044V38.7048C62.7254 38.7612 62.2581 38.8226 61.9183 38.8946C61.5761 38.9671 61.2741 39.0706 61.0109 39.2059C60.3861 39.5162 60.0726 40.0541 60.068 40.8082Z" fill="#3A3A3A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.65791 38.476V40.9071C2.65283 41.2973 2.675 41.5869 2.72487 41.7757C2.7752 41.965 2.88325 42.0897 3.04995 42.1507C3.21665 42.2121 3.47246 42.2463 3.8174 42.2532V42.5926H0V42.2532C0.325544 42.2463 0.566123 42.2121 0.722199 42.1507C0.878275 42.0897 0.979401 41.965 1.02558 41.7757C1.07175 41.5869 1.09207 41.2973 1.08699 40.9071V34.5163C1.09207 34.1261 1.07175 33.8362 1.02742 33.6468C0.982634 33.4575 0.885663 33.3314 0.736514 33.2686C0.586902 33.2054 0.357867 33.1684 0.0480235 33.1569V32.817H3.68488C4.91086 32.812 5.80853 33.0285 6.37927 33.4658C6.94955 33.9036 7.23122 34.5934 7.22476 35.5354C7.22845 36.8598 6.71312 37.7265 5.67831 38.1361C5.40818 38.2418 5.0637 38.3245 4.64534 38.3845C4.22699 38.4441 3.76199 38.4746 3.24989 38.476H2.65791ZM2.65791 38.0968H3.28637C4.04136 38.0945 4.59686 37.9158 4.95334 37.5612C5.14728 37.3557 5.29412 37.087 5.39433 36.754C5.49453 36.4216 5.54486 36.0415 5.54532 35.6139C5.54117 34.0042 4.83652 33.1985 3.43091 33.1961H2.65791V38.0968Z" fill="#3A3A3A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.371 38.8864H8.92881C8.93388 39.3602 8.96159 39.7578 9.01238 40.0787C9.06272 40.3991 9.14399 40.6924 9.25573 40.9579C9.62468 41.8177 10.1945 42.2471 10.9656 42.2471C11.4025 42.2448 11.7987 42.1164 12.1538 41.8616C12.5093 41.6067 12.8044 41.2405 13.0404 40.7621L13.3548 40.9708C13.0454 41.5863 12.6677 42.0384 12.2216 42.327C11.7756 42.6156 11.2312 42.7583 10.5884 42.7555C9.57712 42.7426 8.64759 42.4194 8.06485 41.7867C7.48256 41.1537 7.18565 40.2865 7.1741 39.1861C7.18565 38.0774 7.47286 37.2005 8.03529 36.5545C8.59726 35.909 9.49539 35.5788 10.4628 35.5645C11.1065 35.5663 11.7276 35.7127 12.1685 36.0041C12.6095 36.2959 12.9346 36.7226 13.1447 37.2841C13.2131 37.4706 13.2648 37.684 13.3003 37.9241C13.3354 38.1642 13.359 38.4851 13.371 38.8864ZM8.94127 38.5087H11.7202C11.7091 38.107 11.6874 37.7833 11.6555 37.5381C11.6241 37.2929 11.5742 37.0735 11.5064 36.8805C11.2718 36.1962 10.9112 35.8577 10.4254 35.8642C9.8805 35.8688 9.47369 36.2507 9.2054 37.0107C9.13706 37.2074 9.08211 37.4231 9.04055 37.6572C8.99899 37.8913 8.96575 38.1753 8.94127 38.5087Z" fill="#3A3A3A"/>
|
||||
<path d="M13.655 35.7509L16.119 35.5805C16.1319 35.8262 16.1421 36.033 16.1486 36.1997C16.155 36.3669 16.1583 36.5147 16.1583 36.6421V37.0355C16.6935 36.057 17.3399 35.563 18.0981 35.5542C18.4255 35.5606 18.6915 35.6562 18.8961 35.8409C19.1002 36.0256 19.2059 36.2625 19.2119 36.5507C19.2073 36.7903 19.1186 36.9824 18.9464 37.127C18.7746 37.2719 18.5488 37.3463 18.2685 37.35C17.9319 37.3606 17.7269 37.2212 17.6525 36.9307C17.6041 36.7511 17.5537 36.6287 17.5015 36.5636C17.4498 36.4985 17.3732 36.468 17.2725 36.4717H17.2462C16.9885 36.4939 16.7438 36.6989 16.5124 37.0877C16.4076 37.2821 16.3287 37.4964 16.276 37.7319C16.2238 37.9669 16.1975 38.237 16.1975 38.5427V40.9674C16.1924 41.3544 16.2114 41.6398 16.2539 41.8231C16.2963 42.0064 16.3928 42.1265 16.5434 42.1833C16.6935 42.2396 16.928 42.2715 17.2462 42.2779V42.5928H13.6029V42.2779C13.8998 42.2793 14.1196 42.2507 14.2627 42.1916C14.4059 42.1329 14.4987 42.0101 14.5416 41.8231C14.5841 41.6356 14.603 41.3507 14.5989 40.9674V37.3366C14.6109 36.7963 14.5569 36.4454 14.4364 36.2833C14.3163 36.1212 14.0554 36.0487 13.655 36.0654V35.7509Z" fill="#3A3A3A"/>
|
||||
<path d="M22.139 35.581L22.1524 36.8785C22.6829 36.0049 23.443 35.5634 24.4326 35.5546C25.4397 35.5482 26.0598 36.0067 26.2935 36.9312C26.5784 36.4551 26.9034 36.106 27.2682 35.8839C27.6335 35.6622 28.0597 35.5523 28.5478 35.5546C29.3189 35.5593 29.8869 35.8123 30.2517 36.3147C30.3944 36.505 30.4955 36.7377 30.5565 37.0129C30.617 37.2881 30.6465 37.645 30.6447 38.0842V40.9674C30.6405 41.3507 30.6594 41.6356 30.7019 41.8226C30.7449 42.0101 30.8377 42.1329 30.9808 42.1916C31.124 42.2507 31.3438 42.2793 31.6411 42.2779V42.5928H28.0893V42.2779C28.3732 42.2793 28.5838 42.2507 28.7214 42.1916C28.859 42.1329 28.9486 42.0101 28.9902 41.8226C29.0317 41.6356 29.0502 41.3507 29.046 40.9674V38.0971C29.0488 37.4086 28.9643 36.9118 28.7921 36.6065C28.6194 36.3013 28.3418 36.1517 27.9581 36.1577C27.4973 36.1489 27.1085 36.3891 26.7917 36.8785C26.5188 37.2872 26.383 37.7282 26.3853 38.2024V40.9674C26.3812 41.3507 26.3997 41.6356 26.4417 41.8226C26.4832 42.0101 26.5742 42.1329 26.7137 42.1916C26.8531 42.2507 27.0669 42.2793 27.3551 42.2779V42.5928H23.8429V42.2779C24.1269 42.2793 24.3379 42.2507 24.475 42.1916C24.6126 42.1329 24.7022 42.0101 24.7438 41.8226C24.7853 41.6356 24.8038 41.3507 24.7997 40.9674V38.0842C24.8061 36.7866 24.4524 36.1443 23.7381 36.1577C23.2934 36.1734 22.9207 36.3844 22.6206 36.7899C22.3204 37.1958 22.1644 37.7014 22.1524 38.3072V40.9674C22.1482 41.3549 22.1667 41.6421 22.2082 41.8286C22.2493 42.0156 22.3389 42.1376 22.4765 42.1948C22.6141 42.2521 22.8247 42.2798 23.1087 42.2779V42.5928H19.5568V42.2779C19.8542 42.2798 20.074 42.2521 20.2171 42.1948C20.3603 42.1376 20.4535 42.0156 20.496 41.8286C20.5385 41.6421 20.5574 41.3549 20.5533 40.9674V37.3241C20.5667 36.7908 20.5136 36.4454 20.3926 36.287C20.2721 36.1291 20.0024 36.0552 19.5836 36.0658V35.7514L22.139 35.581Z" fill="#3A3A3A"/>
|
||||
<path d="M40.9592 36.1807V40.5582C40.9587 40.8431 40.9693 41.0791 40.9901 41.2656C41.0113 41.4522 41.0446 41.6097 41.0903 41.7376C41.1457 41.8839 41.2311 42.0008 41.3475 42.088C41.4634 42.1753 41.5918 42.2206 41.7321 42.2224C42.012 42.2183 42.3177 42.083 42.6497 41.8161L42.833 42.1439C42.307 42.5849 41.7081 42.8033 41.0377 42.7992C40.3145 42.7881 39.8209 42.548 39.5568 42.0784C39.5051 41.9745 39.4644 41.8558 39.4358 41.7228C39.4072 41.5898 39.3873 41.4102 39.3762 41.1844C39.3651 40.9581 39.3596 40.6529 39.3601 40.2696V36.1807H38.6129V35.7877C38.8983 35.7767 39.1403 35.7166 39.3388 35.6072C39.5374 35.4978 39.7105 35.3264 39.8583 35.0928C39.9742 34.915 40.0698 34.7169 40.1451 34.4999C40.2199 34.2824 40.2859 34.0127 40.3432 33.6904L40.9984 33.6248C40.9896 33.7985 40.9822 33.9744 40.9767 34.1526C40.9707 34.3309 40.9665 34.5456 40.9638 34.7968C40.9605 35.0475 40.9592 35.3694 40.9592 35.761H42.5449V36.1807H40.9592Z" fill="#3A3A3A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.6066 42.3151V42.5922C38.2228 42.6176 38.0681 42.6171 37.665 42.6176C37.345 42.6181 37.0139 42.6125 36.7392 42.5091C36.5693 42.4454 36.4741 42.3909 36.3476 42.257C36.259 42.1166 36.2026 42.1092 36.174 41.7181C35.836 42.0833 35.4837 42.3521 35.1179 42.5239C34.7518 42.6961 34.3565 42.7811 33.933 42.7797C33.3041 42.7728 32.8123 42.5991 32.4573 42.2583C32.1017 41.9176 31.9211 41.4493 31.9147 40.8527C31.9124 39.7279 32.6119 39.0288 34.012 38.7559C34.6432 38.6335 35.3594 38.5508 36.1611 38.507V37.655C36.168 36.9725 36.0955 36.4992 35.945 36.2365C35.7944 35.9733 35.5257 35.8477 35.1387 35.8597C34.7282 35.8509 34.4354 35.9991 34.2609 36.3053C34.1972 36.4101 34.151 36.5311 34.1214 36.6673C34.0924 36.8035 34.0688 36.9929 34.0513 37.2357C34.0406 37.4689 33.9728 37.6347 33.8481 37.7335C33.7229 37.8323 33.5239 37.8804 33.2515 37.8781C32.6443 37.8734 32.3386 37.6726 32.334 37.275C32.3363 37.0432 32.4078 36.822 32.5491 36.6114C32.69 36.4009 32.8895 36.2157 33.1471 36.0559C33.6929 35.7198 34.4091 35.554 35.2962 35.5582C36.1985 35.554 36.8449 35.7327 37.236 36.0956C37.412 36.2614 37.5413 36.4734 37.6239 36.731C37.707 36.9887 37.7477 37.3184 37.7472 37.7206L37.7209 40.0008C37.7098 40.3743 37.7057 40.6103 37.7075 40.7087C37.7029 41.1811 37.7181 41.5265 37.7523 41.7439C37.7865 41.9619 37.8243 42.0575 37.9675 42.1757C38.2178 42.3188 38.432 42.3147 38.6066 42.3151ZM36.1611 39.8175V38.8085C35.2228 38.8722 34.5574 39.0459 34.1644 39.3294C33.7714 39.6129 33.5807 40.0553 33.5923 40.6565C33.5964 41.1279 33.6934 41.502 33.8823 41.7786C34.0716 42.0552 34.3288 42.1965 34.6539 42.2029C34.9208 42.2002 35.1669 42.1235 35.3927 41.9716C35.6185 41.8206 35.7963 41.6091 35.9251 41.3381C36.0096 41.1718 36.0701 40.9696 36.107 40.7299C36.144 40.4903 36.162 40.1864 36.1611 39.8175Z" fill="#3A3A3A"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.8911 42.3151V42.5922C49.5073 42.6176 49.3527 42.6181 48.9495 42.6185C48.6295 42.619 48.2985 42.6134 48.0237 42.51C47.8538 42.4463 47.7586 42.3918 47.6321 42.2579C47.5435 42.1175 47.4871 42.1101 47.4585 41.719C47.1205 42.0843 46.7682 42.353 46.402 42.5248C46.0363 42.697 45.641 42.782 45.2176 42.7806C44.5886 42.7737 44.0969 42.6001 43.7418 42.2593C43.3862 41.9185 43.2057 41.4503 43.1992 40.8537C43.1969 39.7288 43.896 39.0297 45.2965 38.7568C45.9278 38.6344 46.6439 38.5518 47.4456 38.5079V37.656C47.4525 36.9735 47.38 36.5002 47.2295 36.2374C47.0785 35.9742 46.8097 35.8486 46.4232 35.8606C46.0127 35.8518 45.72 36.0001 45.5454 36.3062C45.4817 36.411 45.4351 36.532 45.406 36.6682C45.3769 36.8045 45.3533 36.9938 45.3358 37.2367C45.3252 37.4699 45.2573 37.6356 45.1326 37.7349C45.0075 37.8333 44.8084 37.8813 44.536 37.879C43.9288 37.8744 43.6231 37.6735 43.6185 37.2759C43.6208 37.0441 43.6924 36.8229 43.8337 36.6124C43.9745 36.4018 44.174 36.2166 44.4312 36.0569C44.9774 35.7207 45.6936 35.5549 46.5807 35.5591C47.483 35.5549 48.1294 35.7336 48.5206 36.0966C48.6965 36.2623 48.8258 36.4743 48.9084 36.732C48.9916 36.9896 49.0322 37.3193 49.0317 37.7215L49.0054 40.0017C48.9943 40.3753 48.9902 40.6112 48.992 40.7096C48.9874 41.182 49.0026 41.5274 49.0368 41.7453C49.071 41.9628 49.1088 42.0584 49.252 42.1766C49.5023 42.3198 49.7165 42.3147 49.8911 42.3151ZM47.4456 39.8184V38.8094C46.5073 38.8732 45.8414 39.0468 45.4489 39.3303C45.0559 39.6138 44.8652 40.0562 44.8768 40.6574C44.8809 41.1289 44.9779 41.5029 45.1668 41.7795C45.3561 42.0561 45.6133 42.1974 45.9384 42.2039C46.2053 42.2011 46.4514 42.1244 46.6772 41.9725C46.903 41.8211 47.0803 41.61 47.2096 41.3385C47.2941 41.1732 47.3546 40.9705 47.3915 40.7308C47.4285 40.4916 47.4465 40.1873 47.4456 39.8184Z" fill="#3A3A3A"/>
|
||||
<path d="M26.3927 9.80802L26.3609 10.4281L38.2584 24.3848L38.6856 24.4546L38.7395 24.4279L38.9483 24.041L33.8745 7.05786L33.0134 6.47754L26.3927 9.80802Z" fill="#0097B9"/>
|
||||
<path d="M45.4856 6.14116L53.1785 9.67814L53.1927 10.676L40.5679 29.7995L39.9916 29.9515L39.9065 29.9124L39.7266 29.4812L44.8151 6.51387L45.4856 6.14116Z" fill="#80B13A"/>
|
||||
<path d="M33.7345 0.699916L34.0408 0.109947L43.8899 0.00329856L44.5264 0.664178L39.8078 16.4294L39.5088 16.603L39.4992 16.6024L39.1758 16.3846L33.7345 0.699916Z" fill="#D81936"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.9112 11.1063L38.7097 24.4917L38.2417 24.4152L26.3289 10.4221L29.9112 11.1063Z" fill="#005874"/>
|
||||
<path d="M33.0258 6.46259L33.8841 7.04518L31.2185 10.5895L29.9807 10.0483L33.0258 6.46259Z" fill="#2FB8E7"/>
|
||||
<path d="M29.9807 10.0483L29.9112 11.1063L26.3289 10.4221L26.3666 9.80685L29.9807 10.0483Z" fill="#007592"/>
|
||||
<path d="M31.2185 10.5895L33.8896 7.03994L39.0274 24.0373L38.7659 24.4769L31.2185 10.5895Z" fill="#007592"/>
|
||||
<path d="M53.2192 10.6736L40.6046 29.8385L39.9596 30.0263L48.0677 11.1121L53.2192 10.6736Z" fill="#6E9123"/>
|
||||
<path d="M46.615 10.3132L39.8871 29.9671L39.6976 29.4849L44.806 6.49426L46.615 10.3132Z" fill="#3F7937"/>
|
||||
<path d="M53.1988 9.67781L53.2192 10.6736L48.0677 11.1121L47.7773 9.74816L53.1988 9.67781Z" fill="#A5C550"/>
|
||||
<path d="M47.7773 9.74816L46.6146 10.3119L44.805 6.49245L45.4698 6.11748L47.7773 9.74816Z" fill="#5D8B27"/>
|
||||
<path d="M38.2598 1.76029L37.5502 2.59873L33.7012 0.681894L34.0121 0.0987324L38.2598 1.76029Z" fill="#AF2C35"/>
|
||||
<path d="M33.7012 0.681894L37.5502 2.59873L39.5017 16.6349L39.1602 16.4023L33.7012 0.681894Z" fill="#762624"/>
|
||||
<path d="M39.4019 2.60073L44.5437 0.657231L39.8319 16.4508L39.5086 16.6346L39.4019 2.60073Z" fill="#B22B37"/>
|
||||
<path d="M38.2598 1.76029L39.4019 2.60073L44.5437 0.657231L43.9003 0L38.2598 1.76029Z" fill="#DC505E"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3802_9601">
|
||||
<rect width="80" height="42.7992" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg width="80" height="30" viewBox="0 0 80 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_26543)">
|
||||
<path d="M76.7227 10.4773H64.024V7.936H76.7227V2.856H56.4053V15.5547H69.104V18.096H56.4053V23.176H76.7227V10.4773Z" fill="black"/>
|
||||
<path d="M53.8667 2.856H48.7867V23.1733H53.8667V2.856Z" fill="black"/>
|
||||
<path d="M25.9307 2.856V7.936H41.168V10.4773H25.9307V23.176H31.008V15.632L38.6293 23.176H46.248L38.2987 15.5547H46.248V2.856H25.9307Z" fill="black"/>
|
||||
<path d="M10.6907 15.5547H15.7573V10.488H10.6907V15.5547ZM11.9627 11.7467H14.5013V14.2853H11.9627V11.7467Z" fill="black"/>
|
||||
<path d="M8.152 2.856H3.70667C3.53867 2.85728 3.37786 2.92428 3.25867 3.04267C3.19937 3.10176 3.15234 3.172 3.12031 3.24935C3.08827 3.3267 3.07185 3.40962 3.072 3.49333V22.5387C3.07185 22.6224 3.08827 22.7053 3.12031 22.7827C3.15234 22.86 3.19937 22.9302 3.25867 22.9893C3.37813 23.107 3.539 23.1731 3.70667 23.1733H15.7707V18.1067H8.152V2.856Z" fill="black"/>
|
||||
<path d="M22.7547 2.856H10.6907V7.936H18.312V15.5547H23.3787V3.49333C23.3802 3.32555 23.3161 3.16381 23.2 3.04267C23.081 2.92563 22.9216 2.8588 22.7547 2.856Z" fill="black"/>
|
||||
<path d="M23.392 18.096H18.312V29.5253H23.392V18.096Z" fill="black"/>
|
||||
<path d="M10.16 0H0.634667C0.466559 0.000701921 0.305536 0.0677938 0.186665 0.186665C0.0677938 0.305536 0.000701921 0.466559 0 0.634667V10.16H1.26933V1.89333C1.27281 1.72708 1.34113 1.56878 1.4597 1.4522C1.57827 1.33562 1.73772 1.26999 1.904 1.26933H10.1707L10.16 0Z" fill="black"/>
|
||||
<path d="M78.7307 16.5067V24.7733C78.73 24.9414 78.6629 25.1025 78.544 25.2213C78.4251 25.3402 78.2641 25.4073 78.096 25.408H69.8293V26.6667H79.3547C79.4388 26.6677 79.5223 26.6521 79.6004 26.6207C79.6785 26.5893 79.7496 26.5428 79.8096 26.4838C79.8696 26.4248 79.9173 26.3545 79.95 26.2769C79.9826 26.1994 79.9997 26.1161 80 26.032V16.5067H78.7307Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3802_26543">
|
||||
<rect width="80" height="29.5253" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,20 +0,0 @@
|
||||
<svg width="80" height="36" viewBox="0 0 80 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3802_18175)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.9554 0.00732258L1.05561 3.19434C1.05561 3.19434 0 3.31692 0 4.58885V27.2859C0 27.2859 0.144209 29.9422 3.17981 29.9422H30.2666C30.2666 29.9422 32.6807 29.3654 32.6807 26.9485V8.22003C32.6807 8.22003 32.7282 5.5637 29.5427 5.5637H3.42208C3.11467 5.4987 2.83195 5.34801 2.6066 5.12904C2.38126 4.91008 2.22251 4.6318 2.14871 4.32639C2.0694 3.90529 2.23957 3.86203 2.68517 3.86203H26.67V1.27492C26.67 1.27492 26.5849 -0.112371 24.9554 0.00732258Z" fill="#E8451E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.8249 26.0904C14.8434 26.0904 13.384 25.2829 11.231 23.1529C11.0547 22.9688 10.9572 22.7232 10.9593 22.4683C10.9614 22.2134 11.063 21.9694 11.2424 21.7884C11.4218 21.6073 11.6648 21.5034 11.9197 21.4989C12.1745 21.4944 12.4211 21.5895 12.6068 21.7642C14.8737 24.0052 15.9236 24.5618 17.7348 24.0715C18.7876 23.7831 19.7321 22.4448 19.7869 20.9061C19.8302 19.7106 18.9866 18.9304 17.2806 18.5887C14.2176 17.9758 12.4092 16.8048 11.9073 15.106C11.5036 13.7389 12.0343 12.2117 13.3624 10.924L13.4172 10.8749C16.2726 8.48826 19.5879 10.2664 20.9421 11.5412C21.1228 11.7206 21.2263 11.9634 21.2308 12.218C21.2352 12.4725 21.1402 12.7188 20.9659 12.9044C20.7915 13.09 20.5517 13.2003 20.2973 13.2118C20.043 13.2233 19.7941 13.1351 19.6038 12.966C19.4899 12.8592 16.9244 10.5288 14.7007 12.3516C13.9407 13.103 13.5975 13.9264 13.7849 14.558C14.0561 15.4781 15.4347 16.2308 17.6656 16.6779C21.4569 17.4364 21.7799 19.9413 21.7424 20.9825C21.6617 23.2509 19.7639 25.7919 17.9829 26.0169C17.5988 26.0658 17.212 26.0904 16.8249 26.0904Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M42.7335 5.4267C42.301 5.16259 41.8328 4.96201 41.3433 4.83112C40.3338 4.5427 39.6056 4.17352 39.258 3.70196C38.948 3.29241 38.948 2.75883 39.3201 2.07528C39.5306 1.66573 40.0411 1.40615 40.6972 1.35424C41.5277 1.32131 42.3515 1.51419 43.081 1.91232C43.1862 1.96491 43.308 1.97357 43.4195 1.93638C43.5311 1.89919 43.6233 1.81922 43.6759 1.71404C43.7285 1.60886 43.7371 1.4871 43.6999 1.37554C43.6628 1.26398 43.5828 1.17176 43.4776 1.11917C42.4609 0.597138 41.4587 0.398129 40.6237 0.461581C39.6546 0.535128 38.8932 0.95766 38.5269 1.63977C37.9674 2.67086 38.0049 3.51449 38.5269 4.22111C39.01 4.85419 39.8911 5.33873 41.0953 5.6733C41.5074 5.78805 41.9033 5.95475 42.2735 6.16938C42.745 6.45779 43.0551 6.79092 43.205 7.15C43.3486 7.48899 43.369 7.86756 43.2627 8.22003C43.2046 8.40269 43.1252 8.5779 43.0262 8.74207C43.0262 8.76658 43.0017 8.7911 42.9772 8.82859C42.6167 9.36216 42.0341 9.63472 41.326 9.63472C40.5444 9.63472 39.5883 9.32467 38.5716 8.70457C38.4274 8.61805 38.2832 8.53008 38.1621 8.44355C38.0634 8.37777 37.9426 8.35388 37.8263 8.37714C37.71 8.40039 37.6077 8.4689 37.542 8.56757C37.4762 8.66625 37.4523 8.78702 37.4755 8.90331C37.4988 9.0196 37.5673 9.12189 37.666 9.18767C37.8116 9.27948 37.9522 9.37914 38.0871 9.48618C39.2537 10.1943 40.3584 10.5404 41.3015 10.5404C42.3196 10.5404 43.1632 10.1438 43.6968 9.34919C43.7184 9.30589 43.7435 9.26442 43.7718 9.22517C43.9158 8.99307 44.0283 8.74284 44.1063 8.48105C44.2729 7.93272 44.2376 7.34287 44.0068 6.81832C43.7387 6.23248 43.2933 5.74569 42.7335 5.4267Z" fill="#E8451E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.5719 4.44608C49.0637 4.32126 48.531 4.33822 48.0317 4.49511C47.4631 4.67076 46.9385 4.96574 46.493 5.36036V0.884114C46.4866 0.770127 46.4367 0.66294 46.3537 0.584534C46.2707 0.506128 46.1609 0.462446 46.0467 0.462446C45.9325 0.462446 45.8227 0.506128 45.7397 0.584534C45.6567 0.66294 45.6069 0.770127 45.6004 0.884114V10.1048C45.6069 10.2188 45.6567 10.326 45.7397 10.4044C45.8227 10.4828 45.9325 10.5265 46.0467 10.5265C46.1609 10.5265 46.2707 10.4828 46.3537 10.4044C46.4367 10.326 46.4866 10.2188 46.493 10.1048V6.65392C46.5019 6.64698 46.5101 6.63926 46.5176 6.63084C47.0771 5.94729 47.6972 5.5262 48.2928 5.34017C48.6343 5.2295 48.9999 5.21652 49.3484 5.30268C49.632 5.37955 49.8811 5.55038 50.055 5.78722C50.2486 6.08717 50.3439 6.44004 50.3275 6.79668V10.0947C50.3275 10.2133 50.3746 10.327 50.4585 10.4109C50.5423 10.4947 50.656 10.5418 50.7746 10.5418C50.8931 10.5418 51.0069 10.4947 51.0907 10.4109C51.1745 10.327 51.2216 10.2133 51.2216 10.0947V6.79236C51.237 6.25299 51.0804 5.72271 50.7746 5.27816C50.4824 4.86958 50.0572 4.57539 49.5719 4.44608Z" fill="#E8451E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M57.1659 9.01318C56.7502 9.42611 56.188 9.65785 55.602 9.65785C55.016 9.65785 54.4538 9.42611 54.038 9.01318C53.6251 8.59741 53.3934 8.03521 53.3934 7.44923C53.3934 6.86325 53.6251 6.30105 54.038 5.88529C54.4554 5.47431 55.017 5.24295 55.6027 5.24067C56.0396 5.24095 56.4667 5.37071 56.83 5.61356C57.1932 5.8564 57.4763 6.20145 57.6436 6.60511C57.8109 7.00877 57.8548 7.45295 57.7698 7.88155C57.6847 8.31014 57.4746 8.70393 57.1659 9.01318ZM55.6027 4.35955C54.7948 4.37701 54.0259 4.71019 53.4607 5.28772C52.8956 5.86525 52.5791 6.64117 52.5791 7.44923C52.5791 8.2573 52.8956 9.03322 53.4607 9.61075C54.0259 10.1883 54.7948 10.5215 55.6027 10.5389C56.4106 10.5215 57.1795 10.1883 57.7447 9.61075C58.3098 9.03322 58.6264 8.2573 58.6264 7.44923C58.6264 6.64117 58.3098 5.86525 57.7447 5.28772C57.1795 4.71019 56.4106 4.37701 55.6027 4.35955Z" fill="#E8451E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.6864 9.01318C64.2705 9.42591 63.7092 9.65893 63.1232 9.66212C62.6863 9.66184 62.2592 9.53208 61.896 9.28924C61.5327 9.04639 61.2496 8.70135 61.0823 8.29768C60.915 7.89402 60.8711 7.44984 60.9562 7.02125C61.0412 6.59265 61.2513 6.19886 61.56 5.88961C61.9766 5.47846 62.5379 5.24704 63.1232 5.245C63.5593 5.24613 63.9852 5.37619 64.3476 5.61882C64.7099 5.86146 64.9924 6.20582 65.1595 6.6086C65.3265 7.01138 65.3708 7.45457 65.2866 7.88244C65.2025 8.3103 64.9936 8.70371 64.6864 9.01318ZM63.1347 4.35955C62.7291 4.35908 62.3273 4.43885 61.9526 4.59428C61.5779 4.74971 61.2376 4.97772 60.9514 5.26518C60.9151 5.29503 60.8818 5.32836 60.8519 5.36469V4.8066C60.8519 4.68804 60.8048 4.57433 60.721 4.49049C60.6371 4.40665 60.5234 4.35955 60.4049 4.35955C60.2863 4.35955 60.1726 4.40665 60.0888 4.49049C60.0049 4.57433 59.9578 4.68804 59.9578 4.8066V13.5918C59.9578 13.7104 60.0049 13.8241 60.0888 13.9079C60.1726 13.9918 60.2863 14.0389 60.4049 14.0389C60.5234 14.0389 60.6371 13.9918 60.721 13.9079C60.8048 13.8241 60.8519 13.7104 60.8519 13.5918V9.53378C60.8879 9.56399 60.9212 9.59729 60.9514 9.63328C61.3107 9.99238 61.753 10.2573 62.2391 10.4047C62.7252 10.552 63.2402 10.5772 63.7384 10.478C64.2366 10.3788 64.7026 10.1582 65.0952 9.83592C65.4878 9.51359 65.7948 9.09942 65.9891 8.63009C66.1834 8.16075 66.259 7.65074 66.2091 7.14523C66.1592 6.63972 65.9855 6.15431 65.7032 5.732C65.4209 5.30968 65.0389 4.9635 64.5909 4.7241C64.1428 4.48471 63.6427 4.35949 63.1347 4.35955Z" fill="#E8451E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M68.4993 6.7664C68.6098 6.40543 68.808 6.07744 69.0762 5.81174C69.2513 5.63445 69.4599 5.4937 69.6899 5.39764C69.9199 5.30157 70.1666 5.25211 70.4159 5.25211C70.6651 5.25211 70.9118 5.30157 71.1418 5.39764C71.3718 5.4937 71.5804 5.63445 71.7556 5.81174C72.022 6.08244 72.2258 6.40833 72.3526 6.7664H68.4993ZM72.3843 5.1801C72.1292 4.91877 71.8241 4.71154 71.4871 4.57081C71.1501 4.43007 70.7883 4.35871 70.4231 4.361C70.0574 4.3549 69.6943 4.42452 69.3568 4.56548C69.0193 4.70644 68.7146 4.9157 68.4618 5.1801C67.3947 6.33377 67.4077 7.57542 67.8288 8.82859C68.0172 9.36277 68.3858 9.81458 68.8714 10.1063C69.3653 10.4143 69.9411 10.5652 70.5226 10.5389C71.2326 10.5127 71.9312 10.3527 72.5819 10.0673C72.639 10.0469 72.6912 10.0149 72.7355 9.97352C72.7799 9.93212 72.8152 9.88211 72.8395 9.82656C72.8638 9.771 72.8766 9.71107 72.8769 9.65044C72.8772 9.5898 72.8652 9.52973 72.8415 9.47391C72.8178 9.41809 72.783 9.36769 72.7391 9.3258C72.6953 9.2839 72.6434 9.2514 72.5865 9.23027C72.5297 9.20915 72.4692 9.19984 72.4086 9.20293C72.348 9.20602 72.2887 9.22144 72.2343 9.24824C71.6903 9.49802 71.1021 9.63723 70.5038 9.65779C70.0945 9.67236 69.6897 9.56927 69.3372 9.36072C69.0246 9.14782 68.7826 8.84657 68.6421 8.49547C68.547 8.23033 68.4807 7.95571 68.4445 7.67636H72.5516C72.7751 7.67636 73.5077 7.72539 73.1284 6.47222C72.9922 5.98508 72.7352 5.54018 72.3814 5.17866L72.3843 5.1801Z" fill="#E8451E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M75.2772 6.7664C75.387 6.40513 75.5853 6.07699 75.854 5.81174C76.0293 5.63441 76.238 5.49363 76.4681 5.39754C76.6982 5.30146 76.9451 5.25199 77.1944 5.25199C77.4438 5.25199 77.6906 5.30146 77.9207 5.39754C78.1508 5.49363 78.3596 5.63441 78.5349 5.81174C78.8008 6.08259 79.0041 6.40846 79.1304 6.7664H75.2772ZM80.0044 7.2322V6.90629C79.9841 6.75953 79.9513 6.61479 79.9063 6.47366C79.7716 5.98493 79.515 5.53837 79.1607 5.17578C78.906 4.91435 78.6011 4.70705 78.2644 4.5663C77.9276 4.42555 77.5659 4.35425 77.2009 4.35667C76.8352 4.35048 76.4721 4.42006 76.1346 4.56103C75.7971 4.702 75.4924 4.9113 75.2397 5.17578C74.1725 6.32945 74.184 7.57109 74.6066 8.82427C74.7946 9.35868 75.1634 9.81062 75.6492 10.102C76.1431 10.41 76.7189 10.5608 77.3004 10.5346C78.0104 10.5084 78.7091 10.3484 79.3597 10.063C79.4168 10.0425 79.4691 10.0106 79.5134 9.9692C79.5577 9.9278 79.5931 9.87779 79.6174 9.82223C79.6417 9.76668 79.6544 9.70675 79.6547 9.64611C79.6551 9.58548 79.643 9.52541 79.6194 9.46959C79.5957 9.41377 79.5608 9.36337 79.517 9.32147C79.4732 9.27958 79.4212 9.24708 79.3644 9.22595C79.3075 9.20482 79.247 9.19552 79.1864 9.19861C79.1259 9.2017 79.0666 9.21712 79.0122 9.24392C78.4682 9.49369 77.88 9.6329 77.2817 9.65347C76.8724 9.66804 76.4675 9.56494 76.115 9.3564C75.8021 9.14388 75.56 8.84252 75.4199 8.49114C75.325 8.22587 75.2583 7.95131 75.2209 7.67204H79.3295C79.4924 7.67348 79.925 7.69944 80 7.2322H80.0044Z" fill="#E8451E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.0307 22.0641H40.393V15.3252H44.575C44.6468 15.3247 44.7186 15.3286 44.7899 15.3368C45.4273 15.4074 48.2105 15.8891 48.2105 18.8742C48.2105 22.0612 45.0279 22.0612 45.0279 22.0612L45.0307 22.0641ZM44.2116 12.7569H37.7828V29.9422H40.393V24.8127H44.4914C45.8669 24.8261 47.2206 24.4695 48.411 23.7802C49.817 22.9481 51.2216 21.4815 51.2216 18.9463C51.2216 16.5683 49.9872 15.0599 48.6749 14.1268C47.3652 13.218 45.8057 12.7383 44.2116 12.754V12.7569Z" fill="#E8451E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M57.6837 27.5758C56.9189 27.5758 56.1714 27.3491 55.5355 26.9243C54.8996 26.4996 54.4038 25.8959 54.1109 25.1895C53.818 24.4831 53.7411 23.7058 53.8899 22.9557C54.0386 22.2056 54.4064 21.5164 54.9468 20.9753C55.4871 20.4341 56.1757 20.0653 56.9256 19.9154C57.6755 19.7655 58.4529 19.8413 59.1598 20.1332C59.8666 20.425 60.471 20.9198 60.8967 21.5551C61.3224 22.1904 61.5502 22.9376 61.5513 23.7023V23.7124C61.5506 24.7375 61.1427 25.7204 60.4174 26.4449C59.6921 27.1694 58.7088 27.5761 57.6837 27.5758ZM61.5513 17.7263V18.9362C60.6493 18.2041 59.5584 17.7426 58.4049 17.6051C57.2514 17.4676 56.0825 17.6598 55.0337 18.1593C53.9849 18.6588 53.0991 19.4453 52.4789 20.4276C51.8588 21.4099 51.5296 22.5478 51.5296 23.7095C51.5296 24.8712 51.8588 26.0091 52.4789 26.9914C53.0991 27.9737 53.9849 28.7602 55.0337 29.2597C56.0825 29.7593 57.2514 29.9514 58.4049 29.8139C59.5584 29.6764 60.6493 29.2149 61.5513 28.4828V29.8514H64.1586V17.7263H61.5513Z" fill="#E8451E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M65.3325 17.5605H68.8195L72.9107 25.391L76.8577 17.5605H80V17.5821L70.6293 35.0242H67.5591L71.1369 28.542L65.3325 17.5605Z" fill="#E8451E"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3802_18175">
|
||||
<rect width="80.0044" height="35.0242" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 11 KiB |
@@ -80,5 +80,19 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<!-- Phase 5.x: halobestie:// custom URL scheme for the Xendit
|
||||
return-page deeplink. See AndroidManifest.xml for the matching
|
||||
Android intent-filter. -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.mybestie</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>halobestie</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -86,6 +86,12 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
||||
String _humanError(DioException e) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
final status = e.response?.statusCode;
|
||||
if (code == 'INVALID_PAYMENT_AMOUNT') {
|
||||
// Server confirms the picker should have caught this — most likely a
|
||||
// stale catalog. The picker's tile subtitle already explains; we just
|
||||
// need to nudge the user to pick a different method.
|
||||
return 'Metode pembayaran tidak cocok untuk nominal ini. Pilih metode lain.';
|
||||
}
|
||||
if (status == 422 || code == 'VALIDATION_ERROR' || code == 'INVALID_TIER') {
|
||||
return 'Pilihan durasi tidak valid.';
|
||||
}
|
||||
@@ -204,6 +210,7 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
||||
group: g,
|
||||
expanded: _expandedGroupIds.contains(g.id),
|
||||
selectedCode: _selectedCode,
|
||||
amount: amount,
|
||||
onToggle: () => setState(() {
|
||||
if (!_expandedGroupIds.add(g.id)) {
|
||||
_expandedGroupIds.remove(g.id);
|
||||
@@ -277,6 +284,7 @@ class _GroupSection extends StatelessWidget {
|
||||
final PaymentMethodGroup group;
|
||||
final bool expanded;
|
||||
final String? selectedCode;
|
||||
final int amount;
|
||||
final VoidCallback onToggle;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@@ -284,6 +292,7 @@ class _GroupSection extends StatelessWidget {
|
||||
required this.group,
|
||||
required this.expanded,
|
||||
required this.selectedCode,
|
||||
required this.amount,
|
||||
required this.onToggle,
|
||||
required this.onSelect,
|
||||
});
|
||||
@@ -334,13 +343,17 @@ class _GroupSection extends StatelessWidget {
|
||||
firstChild: const SizedBox(height: 0),
|
||||
secondChild: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: group.methods
|
||||
.map((m) => _MethodTile(
|
||||
children: group.methods.map((m) {
|
||||
final disabledReason = m.disabledReason(amount);
|
||||
return _MethodTile(
|
||||
method: m,
|
||||
selected: selectedCode == m.paymentCode,
|
||||
onTap: () => onSelect(m.paymentCode),
|
||||
))
|
||||
.toList(),
|
||||
disabledReason: disabledReason,
|
||||
onTap: disabledReason == null
|
||||
? () => onSelect(m.paymentCode)
|
||||
: null,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
crossFadeState:
|
||||
expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
|
||||
@@ -351,21 +364,64 @@ class _GroupSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visual container for one or more brand-mark icons on a payment-method tile.
|
||||
///
|
||||
/// Single-icon: 40×40 box with the icon at 22px. Multi-icon (e.g. credit-card
|
||||
/// row showing Visa + Mastercard + JCB): box widens to fit, icons render at
|
||||
/// 18px with 3px gaps. Empty list: placeholder via [PaymentIcon] in the box.
|
||||
class _MethodIconBox extends StatelessWidget {
|
||||
final List<String> iconUrls;
|
||||
const _MethodIconBox({required this.iconUrls});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final multi = iconUrls.length > 1;
|
||||
final iconSize = multi ? 18.0 : 22.0;
|
||||
final children = iconUrls.isEmpty
|
||||
? <Widget>[
|
||||
PaymentIcon(iconUrl: null, size: iconSize, color: HaloTokens.brandDark),
|
||||
]
|
||||
: [
|
||||
for (var i = 0; i < iconUrls.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 3),
|
||||
PaymentIcon(iconUrl: iconUrls[i], size: iconSize, color: HaloTokens.brandDark),
|
||||
],
|
||||
];
|
||||
return Container(
|
||||
height: 40,
|
||||
constraints: BoxConstraints(minWidth: multi ? 0 : 40),
|
||||
padding: EdgeInsets.symmetric(horizontal: multi ? 6 : 0),
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.md,
|
||||
border: Border.all(color: HaloTokens.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MethodTile extends StatelessWidget {
|
||||
final PaymentMethodEntry method;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final String? disabledReason;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _MethodTile({
|
||||
required this.method,
|
||||
required this.selected,
|
||||
required this.disabledReason,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = disabledReason != null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: HaloSpacing.s8),
|
||||
child: Opacity(
|
||||
opacity: disabled ? 0.5 : 1.0,
|
||||
child: Material(
|
||||
color: selected ? HaloTokens.brandSofter : HaloTokens.surface,
|
||||
borderRadius: HaloRadius.lg,
|
||||
@@ -384,24 +440,13 @@ class _MethodTile extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: HaloTokens.surface,
|
||||
borderRadius: HaloRadius.md,
|
||||
border: Border.all(color: HaloTokens.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: PaymentIcon(
|
||||
slug: method.icon,
|
||||
size: 22,
|
||||
color: HaloTokens.brandDark,
|
||||
),
|
||||
),
|
||||
_MethodIconBox(iconUrls: method.iconUrls),
|
||||
const SizedBox(width: HaloSpacing.s12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
method.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
@@ -409,6 +454,19 @@ class _MethodTile extends StatelessWidget {
|
||||
color: HaloTokens.ink,
|
||||
),
|
||||
),
|
||||
if (disabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
disabledReason!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11.5,
|
||||
color: HaloTokens.inkMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 20,
|
||||
@@ -439,6 +497,7 @@ class _MethodTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,58 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
|
||||
/// One row in the payment-method catalog (server-side: `payment_methods`).
|
||||
///
|
||||
/// `iconUrls` is the backend-resolved list of brand-SVG URLs for this method.
|
||||
/// Relative paths (e.g. `/assets/payment-icons/qris.svg`) are prepended with
|
||||
/// `ApiClient.baseUrl` by [PaymentIcon]. Composite tiles (e.g. credit card
|
||||
/// showing Visa + Mastercard + JCB) carry multiple entries. Empty list = no
|
||||
/// icon configured → the row renders the bundled placeholder.
|
||||
///
|
||||
/// `minAmount` / `maxAmount` are inclusive Rupiah bounds, either nullable
|
||||
/// (null = no bound). The picker greys out methods the current bill misses,
|
||||
/// and the backend enforces the same bounds defensively with
|
||||
/// `INVALID_PAYMENT_AMOUNT`.
|
||||
class PaymentMethodEntry {
|
||||
final String id;
|
||||
final String paymentCode;
|
||||
final String displayName;
|
||||
final String? icon;
|
||||
final List<String> iconUrls;
|
||||
final int? minAmount;
|
||||
final int? maxAmount;
|
||||
|
||||
const PaymentMethodEntry({
|
||||
required this.id,
|
||||
required this.paymentCode,
|
||||
required this.displayName,
|
||||
this.icon,
|
||||
this.iconUrls = const [],
|
||||
this.minAmount,
|
||||
this.maxAmount,
|
||||
});
|
||||
|
||||
/// `null` when the method is usable at [amount]; otherwise a short reason
|
||||
/// suitable as a tile subtitle (Indonesian, brand voice).
|
||||
String? disabledReason(int amount) {
|
||||
if (minAmount != null && amount < minAmount!) {
|
||||
return 'min ${_rp(minAmount!)}';
|
||||
}
|
||||
if (maxAmount != null && amount > maxAmount!) {
|
||||
return 'maks ${_rp(maxAmount!)}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _rp(int n) {
|
||||
// Rp 10.000 (Indonesian thousand-separator). Matches the picker's other
|
||||
// amount formatting via `formatRupiah` in core/constants.dart, kept local
|
||||
// to avoid pulling that dependency into the catalog model.
|
||||
final s = n.toString();
|
||||
final buf = StringBuffer('Rp ');
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
if (i > 0 && (s.length - i) % 3 == 0) buf.write('.');
|
||||
buf.write(s[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
/// One group in the payment-method catalog (server-side:
|
||||
@@ -50,11 +90,13 @@ const _kFallbackGroup = PaymentMethodGroup(
|
||||
id: 'fallback-paling-cepat',
|
||||
name: 'Paling Cepat',
|
||||
methods: [
|
||||
// Fallback path renders the bundled placeholder (iconUrls empty) — the
|
||||
// catalog endpoint being unreachable usually means the icon endpoint is
|
||||
// too, so deferring to the placeholder is the safest signal.
|
||||
PaymentMethodEntry(
|
||||
id: 'fallback-qris',
|
||||
paymentCode: 'QRIS',
|
||||
displayName: 'QRIS',
|
||||
icon: 'qris',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -74,11 +116,14 @@ final paymentCatalogProvider = FutureProvider<PaymentCatalog>((ref) async {
|
||||
final gm = g as Map<String, dynamic>;
|
||||
final methods = (gm['methods'] as List<dynamic>? ?? const []).map((m) {
|
||||
final mm = m as Map<String, dynamic>;
|
||||
final iconUrlsRaw = mm['icon_urls'] as List<dynamic>? ?? const [];
|
||||
return PaymentMethodEntry(
|
||||
id: mm['id'] as String,
|
||||
paymentCode: mm['payment_code'] as String,
|
||||
displayName: mm['display_name'] as String,
|
||||
icon: mm['icon'] as String?,
|
||||
iconUrls: iconUrlsRaw.cast<String>(),
|
||||
minAmount: (mm['min_amount'] as num?)?.toInt(),
|
||||
maxAmount: (mm['max_amount'] as num?)?.toInt(),
|
||||
);
|
||||
}).toList(growable: false);
|
||||
return PaymentMethodGroup(
|
||||
|
||||
@@ -1,56 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/theme/halo_tokens.dart';
|
||||
|
||||
/// Slugs we ship SVGs for. Keep this in sync with `assets/payment_icons/`.
|
||||
/// When a brand mark is added to the bundle (drop the SVG into the asset
|
||||
/// dir), add its slug here. Anything not in this set renders the placeholder.
|
||||
/// Renders a payment-method brand mark fetched from the backend.
|
||||
///
|
||||
/// Source: idn-finlogos (github.com/hafidznoor/idn-finlogos) — see
|
||||
/// `assets/payment_icons/NOTICE_IDN_FINLOGOS.txt` for licensing terms.
|
||||
const Set<String> _kBundledSlugs = {
|
||||
'qris',
|
||||
'ovo',
|
||||
'dana',
|
||||
'shopeepay',
|
||||
'gopay',
|
||||
'bca',
|
||||
'mandiri',
|
||||
'bni',
|
||||
'bri',
|
||||
'permata',
|
||||
};
|
||||
|
||||
/// Renders a payment-method brand mark by slug (`payment_methods.icon`).
|
||||
/// Falls back to a generic credit-card placeholder when the slug isn't
|
||||
/// bundled. Slugs are kept lower-case by convention.
|
||||
/// `iconUrl` comes from `payment_methods.icon_url` on the catalog response.
|
||||
/// Relative paths (the common case — `/assets/payment-icons/<slug>.svg`) are
|
||||
/// resolved against [ApiClient.baseUrl]. Absolute URLs (operator override)
|
||||
/// are used as-is.
|
||||
///
|
||||
/// First fetch hits the network; the file is then persisted to disk by
|
||||
/// [DefaultCacheManager] (30-day idle LRU) and served locally on subsequent
|
||||
/// renders. While the cache lookup is in flight or when [iconUrl] is null,
|
||||
/// the bundled placeholder is shown so the picker never displays a spinner.
|
||||
class PaymentIcon extends StatelessWidget {
|
||||
final String? slug;
|
||||
final String? iconUrl;
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
const PaymentIcon({
|
||||
super.key,
|
||||
required this.slug,
|
||||
required this.iconUrl,
|
||||
this.size = 24,
|
||||
this.color = HaloTokens.brandDark,
|
||||
});
|
||||
|
||||
String? get _resolvedUrl {
|
||||
final raw = iconUrl;
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
|
||||
return '${ApiClient.baseUrl}$raw';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBundled = slug != null && _kBundledSlugs.contains(slug);
|
||||
final asset = isBundled
|
||||
? 'assets/payment_icons/$slug.svg'
|
||||
: 'assets/payment_icons/placeholder.svg';
|
||||
final url = _resolvedUrl;
|
||||
if (url == null) return _placeholder();
|
||||
|
||||
// Brand SVGs ship with their canonical colors and must NOT be tinted;
|
||||
// the placeholder is mono-color and DOES want the brand-dark tint.
|
||||
return SvgPicture.asset(
|
||||
asset,
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: isBundled ? null : ColorFilter.mode(color, BlendMode.srcIn),
|
||||
return FutureBuilder<FileInfo?>(
|
||||
future: DefaultCacheManager().getFileFromCache(url),
|
||||
builder: (context, cachedSnap) {
|
||||
final cached = cachedSnap.data?.file;
|
||||
if (cached != null) {
|
||||
return SvgPicture.file(cached, width: size, height: size);
|
||||
}
|
||||
// Cache miss: render placeholder while the download lands, then swap
|
||||
// in. We fire the download in the same FutureBuilder body so the next
|
||||
// rebuild picks up the freshly-cached file.
|
||||
return FutureBuilder(
|
||||
future: DefaultCacheManager().getSingleFile(url),
|
||||
builder: (context, snap) {
|
||||
if (snap.hasData) {
|
||||
return SvgPicture.file(snap.data!, width: size, height: size);
|
||||
}
|
||||
return _placeholder();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() => SvgPicture.asset(
|
||||
'assets/payment_icons/placeholder.svg',
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import flutter_secure_storage_macos
|
||||
import google_sign_in_ios
|
||||
import shared_preferences_foundation
|
||||
import sign_in_with_apple
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@@ -22,5 +23,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
@@ -358,6 +358,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_cache_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
flutter_hooks:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1132,6 +1140,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+1"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.8"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1172,6 +1220,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0+1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1389,5 +1445,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
@@ -42,12 +42,13 @@ dependencies:
|
||||
# (mock mode encodes payment_session_id; real QR will come from Xendit later).
|
||||
qr_flutter: ^4.1.0
|
||||
|
||||
# Payment method icons (Phase 5.x catalog) — bundled SVGs under
|
||||
# assets/payment_icons/, copied from github.com/hafidznoor/idn-finlogos.
|
||||
# Xendit's per-channel media-asset pages were planned but found
|
||||
# decommissioned during implementation. See
|
||||
# `requirement/phase5-payment-catalog-plan.md` §7 for the sourcing decision.
|
||||
# Payment method icons (Phase 5.x catalog). Source-of-truth is the backend
|
||||
# `/assets/payment-icons/<slug>.svg` endpoint, which wraps the `idn-finlogos`
|
||||
# npm package. Mobile only bundles `placeholder.svg` as the first-launch /
|
||||
# offline fallback; everything else is fetched on demand and persisted via
|
||||
# `flutter_cache_manager` for far-future reuse.
|
||||
flutter_svg: ^2.0.10+1
|
||||
flutter_cache_manager: ^3.3.1
|
||||
|
||||
# OS notification permission — used by the post-payment notif gate
|
||||
# (Phase 4 Stage 4) and the home banner.
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Halo Bestie Control Center</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;600;700&family=Poppins:wght@400;500;600;700&display=swap" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
control_center/src/assets/logo.png
Executable file
|
After Width: | Height: | Size: 275 KiB |
@@ -2,6 +2,18 @@ import { useState } from 'react'
|
||||
import { Outlet, NavLink } from 'react-router-dom'
|
||||
import { useAuth } from '../core/auth/AuthContext'
|
||||
import { apiClient } from '../core/api/api-client'
|
||||
import HBLogo from './ui/HBLogo'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/dashboard', label: 'Dashboard' },
|
||||
{ to: '/mitras', label: 'Mitra' },
|
||||
{ to: '/sessions', label: 'Sesi' },
|
||||
{ to: '/failed-pairings', label: 'Failed Pairings' },
|
||||
{ to: '/users', label: 'Users' },
|
||||
{ to: '/mitra-activity', label: 'Aktivitas Mitra' },
|
||||
{ to: '/payment-catalog', label: 'Payment Catalog' },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
const PasswordChangeForm = ({ onDone }) => {
|
||||
const [current, setCurrent] = useState('')
|
||||
@@ -34,18 +46,33 @@ const PasswordChangeForm = ({ onDone }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ padding: 8, border: '1px solid #eee', marginTop: 8 }}>
|
||||
<input type="password" placeholder="Password lama" value={current}
|
||||
onChange={e => setCurrent(e.target.value)} required
|
||||
style={{ display: 'block', width: '100%', marginBottom: 6 }} />
|
||||
<input type="password" placeholder="Password baru (min 8, huruf besar/kecil + angka)" value={next}
|
||||
onChange={e => setNext(e.target.value)} required minLength={8}
|
||||
style={{ display: 'block', width: '100%', marginBottom: 6 }} />
|
||||
{error && <p style={{ color: 'red', margin: '4px 0', fontSize: 12 }}>{error}</p>}
|
||||
{success && <p style={{ color: 'green', margin: '4px 0', fontSize: 12 }}>Password berhasil diubah.</p>}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button type="submit" disabled={saving}>{saving ? '...' : 'Simpan'}</button>
|
||||
<button type="button" onClick={onDone}>Tutup</button>
|
||||
<form onSubmit={submit} className="hb-card" style={{ marginTop: 10, padding: 14 }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password lama"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
required
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password baru (min 8, huruf besar/kecil + angka)"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
{error && <p className="hb-error">{error}</p>}
|
||||
{success && <p className="hb-success">Password berhasil diubah.</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" disabled={saving} style={{ flex: 1 }}>
|
||||
{saving ? 'Menyimpan…' : 'Simpan'}
|
||||
</button>
|
||||
<button type="button" className="hb-btn-secondary" onClick={onDone}>
|
||||
Tutup
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
@@ -56,27 +83,111 @@ export default function Layout() {
|
||||
const [showPwForm, setShowPwForm] = useState(false)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<nav style={{ width: 220, borderRight: '1px solid #eee', padding: 16 }}>
|
||||
<h2>Control Center</h2>
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
<li><NavLink to="/dashboard">Dashboard</NavLink></li>
|
||||
<li><NavLink to="/mitras">Mitra</NavLink></li>
|
||||
<li><NavLink to="/sessions">Sesi</NavLink></li>
|
||||
<li><NavLink to="/failed-pairings">Failed Pairings</NavLink></li>
|
||||
<li><NavLink to="/users">Users</NavLink></li>
|
||||
<li><NavLink to="/mitra-activity">Aktivitas Mitra</NavLink></li>
|
||||
<li><NavLink to="/payment-catalog">Payment Catalog</NavLink></li>
|
||||
<li><NavLink to="/settings">Settings</NavLink></li>
|
||||
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--hb-bg)' }}>
|
||||
<nav
|
||||
style={{
|
||||
width: 260,
|
||||
padding: '24px 20px',
|
||||
background: 'var(--hb-surface)',
|
||||
borderRight: '1px solid var(--hb-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 24,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<HBLogo size={44} />
|
||||
<div style={{ lineHeight: 1.15 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--hb-font-display)',
|
||||
fontWeight: 700,
|
||||
fontSize: 17,
|
||||
color: 'var(--hb-ink)',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Halo Bestie
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--hb-brand-dark)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Control Center
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) => `hb-nav-link${isActive ? ' active' : ''}`}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ marginTop: 'auto', paddingTop: 16 }}>
|
||||
<p style={{ fontSize: 12 }}>{user?.email}</p>
|
||||
<button onClick={() => setShowPwForm(v => !v)} style={{ marginRight: 6 }}>Ganti password</button>
|
||||
<button onClick={logout}>Logout</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid var(--hb-border)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="hb-soft"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
margin: '0 0 10px',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{user?.email}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="hb-btn-ghost"
|
||||
onClick={() => setShowPwForm((v) => !v)}
|
||||
style={{ flex: 1, minHeight: 36, fontSize: 13 }}
|
||||
>
|
||||
Ganti password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hb-btn-secondary"
|
||||
onClick={logout}
|
||||
style={{ minHeight: 36, fontSize: 13 }}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
{showPwForm && <PasswordChangeForm onDone={() => setShowPwForm(false)} />}
|
||||
</div>
|
||||
</nav>
|
||||
<main style={{ flex: 1, padding: 24 }}>
|
||||
|
||||
<main style={{ flex: 1, padding: '28px 32px', minWidth: 0 }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
36
control_center/src/components/ui/HBLogo.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// Brand mark — mirrors client_app/lib/features/splash/splash_screen.dart:
|
||||
// pink #FF699F rounded-square tile, logo image scaled to 1.4× (the source PNG
|
||||
// has ~25% internal whitespace), soft shadow.
|
||||
import logoUrl from '../../assets/logo.png'
|
||||
|
||||
export default function HBLogo({ size = 56, radius, shadow = true, style = {} }) {
|
||||
const r = radius ?? Math.round(size * 0.25) // matches Flutter radius:24 on 96px
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: r,
|
||||
background: '#FF699F',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: shadow ? 'var(--hb-shadow-soft)' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="Halo Bestie"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '140%',
|
||||
height: '140%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AuthProvider } from './core/auth/AuthContext'
|
||||
import './theme/global.css'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
@@ -6,6 +6,67 @@ const fetchDashboardStats = async () => {
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
const StatCard = ({ label, value, suffix, accent, tone = 'neutral' }) => {
|
||||
const toneStyles = {
|
||||
neutral: { bg: 'var(--hb-surface)', accent: accent || 'var(--hb-brand-dark)' },
|
||||
brand: { bg: 'var(--hb-brand-softer)', accent: 'var(--hb-brand-dark)' },
|
||||
success: { bg: '#E9F4ED', accent: 'var(--hb-success)' },
|
||||
warn: { bg: 'var(--hb-accent-soft)', accent: '#A06B22' },
|
||||
sensitive: { bg: '#FFF6E5', accent: '#B88900' },
|
||||
}[tone]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hb-card"
|
||||
style={{
|
||||
padding: 22,
|
||||
background: toneStyles.bg,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--hb-ink-soft)',
|
||||
letterSpacing: '-0.005em',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--hb-font-display)',
|
||||
fontSize: 34,
|
||||
fontWeight: 700,
|
||||
color: toneStyles.accent,
|
||||
letterSpacing: '-0.02em',
|
||||
lineHeight: 1.1,
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
{suffix && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontFamily: 'var(--hb-font-body)',
|
||||
fontWeight: 500,
|
||||
color: 'var(--hb-ink-soft)',
|
||||
}}
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
@@ -13,59 +74,57 @@ export default function DashboardPage() {
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
|
||||
if (isLoading) return <div>Loading...</div>
|
||||
if (isLoading) return <div className="hb-soft">Loading…</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 32 }}>
|
||||
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#2563eb' }}>{data?.active_chats ?? 0}</div>
|
||||
<div style={{ color: '#666' }}>Chat Aktif</div>
|
||||
</div>
|
||||
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#16a34a' }}>{data?.online_mitras ?? 0}</div>
|
||||
<div style={{ color: '#666' }}>Mitra Online</div>
|
||||
</div>
|
||||
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#f59e0b' }}>{data?.pending_requests ?? 0}</div>
|
||||
<div style={{ color: '#666' }}>Request Pending</div>
|
||||
</div>
|
||||
<div style={{ padding: 24, border: '1px solid #eee', borderRadius: 8, background: '#FFF8DF' }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 'bold', color: '#B88900' }}>
|
||||
{data?.sensitive?.last_30d_sensitive ?? 0}
|
||||
<span style={{ fontSize: 16, color: '#666', marginLeft: 8 }}>
|
||||
({data?.sensitive?.last_30d_percent ?? 0}%)
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: '#666' }}>Sesi Sensitif (30 hari)</div>
|
||||
<div style={{ color: '#888', fontSize: 12, marginTop: 4 }}>
|
||||
Total semua waktu: {data?.sensitive?.total ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<StatCard label="Chat Aktif" value={data?.active_chats ?? 0} tone="brand" />
|
||||
<StatCard label="Mitra Online" value={data?.online_mitras ?? 0} tone="success" />
|
||||
<StatCard label="Request Pending" value={data?.pending_requests ?? 0} tone="warn" />
|
||||
<StatCard
|
||||
label="Sesi Sensitif (30 hari)"
|
||||
value={data?.sensitive?.last_30d_sensitive ?? 0}
|
||||
suffix={`(${data?.sensitive?.last_30d_percent ?? 0}%)`}
|
||||
tone="sensitive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data?.sensitive && (
|
||||
<p className="hb-muted" style={{ fontSize: 12, marginTop: -16, marginBottom: 24 }}>
|
||||
Total sesi sensitif sepanjang waktu: {data.sensitive.total ?? 0}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<h2>Customer per Mitra</h2>
|
||||
{data?.customers_per_mitra?.length > 0 ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Mitra</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Sesi Aktif</th>
|
||||
<th>Mitra</th>
|
||||
<th>Sesi Aktif</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.customers_per_mitra.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td style={{ padding: 8 }}>{m.display_name}</td>
|
||||
<td style={{ padding: 8 }}>{m.active_session_count}</td>
|
||||
<td>{m.display_name}</td>
|
||||
<td>{m.active_session_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p style={{ color: '#666' }}>Tidak ada mitra online.</p>
|
||||
<p className="hb-soft">Tidak ada mitra online.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -98,12 +98,12 @@ export default function FailedPairingsPage() {
|
||||
<div>
|
||||
<h1>Failed Pairings</h1>
|
||||
|
||||
<div style={{ marginBottom: 16, padding: 12, border: '1px solid #eee', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className="hb-card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<strong style={{ marginRight: 8 }}>Cause:</strong>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 4 }}>
|
||||
<strong style={{ marginRight: 8, color: 'var(--hb-ink)' }}>Cause:</strong>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
|
||||
{CAUSE_OPTIONS.map((opt) => (
|
||||
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 13 }}>
|
||||
<label key={opt.value} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, margin: 0, color: 'var(--hb-ink)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCauses.includes(opt.value)}
|
||||
@@ -116,49 +116,51 @@ export default function FailedPairingsPage() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<label style={{ fontSize: 13 }}>From:</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label style={{ fontSize: 13, margin: 0 }}>From:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1) }}
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<label style={{ fontSize: 13 }}>To:</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label style={{ fontSize: 13, margin: 0 }}>To:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1) }}
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={clearFilters} style={{ fontSize: 12 }}>Clear filters</button>
|
||||
<button className="hb-btn-ghost" onClick={clearFilters} style={{ fontSize: 12, minHeight: 32 }}>Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <div>Loading...</div>}
|
||||
{isError && <p style={{ color: 'red' }}>Gagal memuat data failed pairings.</p>}
|
||||
{isLoading && <div className="hb-soft">Loading…</div>}
|
||||
{isError && <p className="hb-error">Gagal memuat data failed pairings.</p>}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Created</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Targeted Mitra</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Cause</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Amount</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Operator Action</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned By</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Actioned At</th>
|
||||
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
|
||||
<th>Created</th>
|
||||
<th>Customer</th>
|
||||
<th>Targeted Mitra</th>
|
||||
<th>Cause</th>
|
||||
<th>Amount</th>
|
||||
<th>Operator Action</th>
|
||||
<th>Actioned By</th>
|
||||
<th>Actioned At</th>
|
||||
<th>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} style={{ padding: 24, textAlign: 'center', color: '#666' }}>
|
||||
<td colSpan={9} style={{ padding: 28, textAlign: 'center', color: 'var(--hb-ink-muted)' }}>
|
||||
Belum ada data failed pairings.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -167,23 +169,24 @@ export default function FailedPairingsPage() {
|
||||
const canAction = !row.operator_action
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td style={{ padding: 8 }}>{formatDateTime(row.created_at)}</td>
|
||||
<td style={{ padding: 8 }}>{row.customer_call_name ?? '-'}</td>
|
||||
<td style={{ padding: 8 }}>{row.targeted_mitra_call_name ?? '-'}</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<td>{formatDateTime(row.created_at)}</td>
|
||||
<td>{row.customer_call_name ?? '-'}</td>
|
||||
<td>{row.targeted_mitra_call_name ?? '-'}</td>
|
||||
<td>
|
||||
{PairingFailureCauseLabel[row.cause_tag] ?? row.cause_tag}
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>{formatRupiah(row.amount)}</td>
|
||||
<td style={{ padding: 8 }}>{operatorActionLabel(row)}</td>
|
||||
<td style={{ padding: 8 }}>{row.actioned_by_name ?? '-'}</td>
|
||||
<td style={{ padding: 8 }}>{formatDateTime(row.actioned_at)}</td>
|
||||
<td style={{ padding: 8, position: 'relative' }}>
|
||||
<td>{formatRupiah(row.amount)}</td>
|
||||
<td>{operatorActionLabel(row)}</td>
|
||||
<td>{row.actioned_by_name ?? '-'}</td>
|
||||
<td>{formatDateTime(row.actioned_at)}</td>
|
||||
<td style={{ position: 'relative' }}>
|
||||
{canAction ? (
|
||||
<>
|
||||
<button
|
||||
className="hb-btn-secondary"
|
||||
onClick={() => setOpenMenuId(openMenuId === row.id ? null : row.id)}
|
||||
disabled={actionMutation.isPending}
|
||||
style={{ fontSize: 12 }}
|
||||
style={{ fontSize: 12, minHeight: 32, padding: '6px 12px' }}
|
||||
>
|
||||
Action
|
||||
</button>
|
||||
@@ -192,11 +195,14 @@ export default function FailedPairingsPage() {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '100%',
|
||||
background: 'white',
|
||||
border: '1px solid #ddd',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.08)',
|
||||
background: 'var(--hb-surface)',
|
||||
border: '1px solid var(--hb-border)',
|
||||
borderRadius: 'var(--hb-radius-md)',
|
||||
boxShadow: 'var(--hb-shadow-card)',
|
||||
zIndex: 10,
|
||||
minWidth: 180,
|
||||
minWidth: 200,
|
||||
overflow: 'hidden',
|
||||
marginTop: 4,
|
||||
}}>
|
||||
<button
|
||||
style={menuItemStyle}
|
||||
@@ -211,7 +217,7 @@ export default function FailedPairingsPage() {
|
||||
Mark as credited
|
||||
</button>
|
||||
<button
|
||||
style={menuItemStyle}
|
||||
style={{ ...menuItemStyle, borderBottom: 'none' }}
|
||||
onClick={() => actionMutation.mutate({ id: row.id, action: PairingFailureOperatorAction.NO_ACTION })}
|
||||
>
|
||||
Mark as no-action
|
||||
@@ -220,7 +226,7 @@ export default function FailedPairingsPage() {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: '#999', fontSize: 12 }}>—</span>
|
||||
<span className="hb-muted" style={{ fontSize: 12 }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -229,16 +235,16 @@ export default function FailedPairingsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Prev</button>
|
||||
<span>Page {page} of {totalPages} ({total} total)</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 12, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<button className="hb-btn-secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Prev</button>
|
||||
<span className="hb-soft" style={{ fontSize: 13 }}>Page {page} of {totalPages} ({total} total)</span>
|
||||
<button className="hb-btn-secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{actionMutation.isError && (
|
||||
<p style={{ color: 'red', marginTop: 8 }}>Gagal menyimpan operator action.</p>
|
||||
<p className="hb-error" style={{ marginTop: 8 }}>Gagal menyimpan operator action.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -247,11 +253,16 @@ export default function FailedPairingsPage() {
|
||||
const menuItemStyle = {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: 'white',
|
||||
padding: '10px 14px',
|
||||
background: 'var(--hb-surface)',
|
||||
color: 'var(--hb-ink)',
|
||||
border: 'none',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
borderRadius: 0,
|
||||
borderBottom: '1px solid var(--hb-border)',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
minHeight: 36,
|
||||
boxShadow: 'none',
|
||||
fontWeight: 500,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../../core/auth/AuthContext'
|
||||
import HBLogo from '../../components/ui/HBLogo'
|
||||
|
||||
const messageForError = (err) => {
|
||||
const code = err?.response?.data?.error?.code
|
||||
@@ -41,26 +42,93 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) return <div style={{ padding: 24 }}>Loading...</div>
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div style={{ padding: 24, color: 'var(--hb-ink-soft)' }}>Loading…</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 360, margin: '100px auto', padding: 24 }}>
|
||||
<h1>Halo Bestie</h1>
|
||||
<h2>Control Center</h2>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background:
|
||||
'radial-gradient(1100px 540px at 50% -120px, var(--hb-brand-softer), transparent 70%), var(--hb-bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="hb-card"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
padding: 32,
|
||||
boxShadow: 'var(--hb-shadow-card)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<HBLogo size={72} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h1 style={{ margin: 0, fontSize: 24 }}>Halo Bestie</h1>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--hb-brand-dark)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
Control Center
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<div className="hb-form-row">
|
||||
<label htmlFor="cc-login-email">Email</label>
|
||||
<input id="cc-login-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
|
||||
<input
|
||||
id="cc-login-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="hb-form-row">
|
||||
<label htmlFor="cc-login-password">Password</label>
|
||||
<input id="cc-login-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required style={{ display: 'block', width: '100%', marginBottom: 12 }} />
|
||||
<input
|
||||
id="cc-login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} style={{ width: '100%' }}>
|
||||
{loading ? 'Loading...' : 'Masuk'}
|
||||
{error && <p className="hb-error">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', marginTop: 8, minHeight: 48, fontSize: 15 }}
|
||||
>
|
||||
{loading ? 'Memproses…' : 'Masuk'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ const fetchMitras = async () => {
|
||||
|
||||
const responseColor = (response) => {
|
||||
switch (response) {
|
||||
case 'accepted': return '#22c55e'
|
||||
case 'declined': return '#ef4444'
|
||||
case 'missed': return '#f97316'
|
||||
case 'ignored': return '#9ca3af'
|
||||
default: return '#6b7280'
|
||||
case 'accepted': return 'var(--hb-success)'
|
||||
case 'declined': return 'var(--hb-danger)'
|
||||
case 'missed': return '#C97A2A'
|
||||
case 'ignored': return 'var(--hb-ink-muted)'
|
||||
default: return 'var(--hb-ink-soft)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,10 @@ export default function MitraActivityPage() {
|
||||
<div>
|
||||
<h1>Aktivitas Mitra</h1>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 24, flexWrap: 'wrap', alignItems: 'end' }}>
|
||||
<div className="hb-card" style={{ display: 'flex', gap: 16, marginBottom: 24, flexWrap: 'wrap', alignItems: 'end' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>Mitra</label>
|
||||
<select value={mitraFilter} onChange={e => { setMitraFilter(e.target.value); setLogPage(1) }} style={{ padding: '6px 8px' }}>
|
||||
<label>Mitra</label>
|
||||
<select value={mitraFilter} onChange={e => { setMitraFilter(e.target.value); setLogPage(1) }} style={{ width: 'auto', minWidth: 200 }}>
|
||||
<option value="">Semua Mitra</option>
|
||||
{(mitras || []).map(m => (
|
||||
<option key={m.id} value={m.id}>{m.display_name}</option>
|
||||
@@ -79,32 +79,32 @@ export default function MitraActivityPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>Dari</label>
|
||||
<input type="date" value={dateFrom} onChange={e => { setDateFrom(e.target.value); setLogPage(1) }} />
|
||||
<label>Dari</label>
|
||||
<input type="date" value={dateFrom} onChange={e => { setDateFrom(e.target.value); setLogPage(1) }} style={{ width: 'auto' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>Sampai</label>
|
||||
<input type="date" value={dateTo} onChange={e => { setDateTo(e.target.value); setLogPage(1) }} />
|
||||
<label>Sampai</label>
|
||||
<input type="date" value={dateTo} onChange={e => { setDateTo(e.target.value); setLogPage(1) }} style={{ width: 'auto' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h2>Ringkasan</h2>
|
||||
{summaryLoading ? <p>Loading...</p> : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
{summaryLoading ? <p className="hb-soft">Loading…</p> : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd', textAlign: 'left' }}>
|
||||
<th style={{ padding: 8 }}>Mitra</th>
|
||||
<th style={{ padding: 8 }}>Total</th>
|
||||
<th style={{ padding: 8 }}>Accepted</th>
|
||||
<th style={{ padding: 8 }}>Rejected</th>
|
||||
<th style={{ padding: 8 }}>Missed</th>
|
||||
<th style={{ padding: 8 }}>Ignored</th>
|
||||
<th style={{ padding: 8 }}>Rate (%)</th>
|
||||
<th style={{ padding: 8 }}>Avg Response (s)</th>
|
||||
<th style={{ padding: 8, background: '#FFF8DF' }}>Sensitif Total</th>
|
||||
<th style={{ padding: 8, background: '#FFF8DF' }}>Sensitif Diterima</th>
|
||||
<th style={{ padding: 8, background: '#FFF8DF' }}>Sensitif Rate (%)</th>
|
||||
<tr>
|
||||
<th>Mitra</th>
|
||||
<th>Total</th>
|
||||
<th>Accepted</th>
|
||||
<th>Rejected</th>
|
||||
<th>Missed</th>
|
||||
<th>Ignored</th>
|
||||
<th>Rate (%)</th>
|
||||
<th>Avg Response (s)</th>
|
||||
<th style={{ background: 'var(--hb-accent-soft)', color: '#8A5A20' }}>Sensitif Total</th>
|
||||
<th style={{ background: 'var(--hb-accent-soft)', color: '#8A5A20' }}>Sensitif Diterima</th>
|
||||
<th style={{ background: 'var(--hb-accent-soft)', color: '#8A5A20' }}>Sensitif Rate (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -113,25 +113,25 @@ export default function MitraActivityPage() {
|
||||
const sensRate = s.sensitive_acceptance_rate != null ? Number(s.sensitive_acceptance_rate) : null
|
||||
const flagSensRate = overall != null && sensRate != null && (overall - sensRate) >= 20
|
||||
return (
|
||||
<tr key={s.mitra_id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: 8 }}>{s.mitra_display_name}</td>
|
||||
<td style={{ padding: 8 }}>{s.total_requests}</td>
|
||||
<td style={{ padding: 8, color: '#22c55e' }}>{s.accepted_count}</td>
|
||||
<td style={{ padding: 8, color: '#ef4444' }}>{s.rejected_count}</td>
|
||||
<td style={{ padding: 8, color: '#f97316' }}>{s.missed_count}</td>
|
||||
<td style={{ padding: 8, color: '#9ca3af' }}>{s.ignored_count}</td>
|
||||
<td style={{ padding: 8 }}>{s.acceptance_rate ?? '-'}%</td>
|
||||
<td style={{ padding: 8 }}>{s.avg_response_time_seconds ?? '-'}</td>
|
||||
<td style={{ padding: 8 }}>{s.sensitive_total || 0}</td>
|
||||
<td style={{ padding: 8 }}>{s.sensitive_accepted || 0}</td>
|
||||
<td style={{ padding: 8, color: flagSensRate ? '#ef4444' : undefined, fontWeight: flagSensRate ? 'bold' : undefined }}>
|
||||
<tr key={s.mitra_id}>
|
||||
<td>{s.mitra_display_name}</td>
|
||||
<td>{s.total_requests}</td>
|
||||
<td style={{ color: 'var(--hb-success)', fontWeight: 600 }}>{s.accepted_count}</td>
|
||||
<td style={{ color: 'var(--hb-danger)', fontWeight: 600 }}>{s.rejected_count}</td>
|
||||
<td style={{ color: '#C97A2A', fontWeight: 600 }}>{s.missed_count}</td>
|
||||
<td className="hb-muted">{s.ignored_count}</td>
|
||||
<td>{s.acceptance_rate ?? '-'}%</td>
|
||||
<td>{s.avg_response_time_seconds ?? '-'}</td>
|
||||
<td>{s.sensitive_total || 0}</td>
|
||||
<td>{s.sensitive_accepted || 0}</td>
|
||||
<td style={{ color: flagSensRate ? 'var(--hb-danger)' : undefined, fontWeight: flagSensRate ? 700 : undefined }}>
|
||||
{(s.sensitive_total || 0) === 0 ? '—' : `${s.sensitive_acceptance_rate ?? 0}%`}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{(!summary || summary.length === 0) && (
|
||||
<tr><td colSpan={11} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
|
||||
<tr><td colSpan={11} style={{ padding: 24, textAlign: 'center', color: 'var(--hb-ink-muted)' }}>Tidak ada data</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -140,54 +140,54 @@ export default function MitraActivityPage() {
|
||||
|
||||
<section>
|
||||
<h2>Detail Log</h2>
|
||||
{logLoading ? <p>Loading...</p> : (
|
||||
{logLoading ? <p className="hb-soft">Loading…</p> : (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd', textAlign: 'left' }}>
|
||||
<th style={{ padding: 8 }}>Mitra</th>
|
||||
<th style={{ padding: 8 }}>Session</th>
|
||||
<th style={{ padding: 8 }}>Topik</th>
|
||||
<th style={{ padding: 8 }}>Response</th>
|
||||
<th style={{ padding: 8 }}>Response Time (s)</th>
|
||||
<th style={{ padding: 8 }}>Active Sessions</th>
|
||||
<th style={{ padding: 8 }}>Notified At</th>
|
||||
<th style={{ padding: 8 }}>Responded At</th>
|
||||
<tr>
|
||||
<th>Mitra</th>
|
||||
<th>Session</th>
|
||||
<th>Topik</th>
|
||||
<th>Response</th>
|
||||
<th>Response Time (s)</th>
|
||||
<th>Active Sessions</th>
|
||||
<th>Notified At</th>
|
||||
<th>Responded At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(logData?.items || []).map(item => (
|
||||
<tr key={item.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: 8 }}>{item.mitra_display_name}</td>
|
||||
<td style={{ padding: 8, fontSize: 11, fontFamily: 'monospace' }}>{item.session_id?.substring(0, 8)}...</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<tr key={item.id}>
|
||||
<td>{item.mitra_display_name}</td>
|
||||
<td style={{ fontSize: 11, fontFamily: 'var(--hb-font-mono)' }}>{item.session_id?.substring(0, 8)}…</td>
|
||||
<td>
|
||||
{item.topic_sensitivity === 'sensitive' ? (
|
||||
<span style={{ background: '#F7B500', color: '#3D2A00', padding: '2px 6px', borderRadius: 999, fontSize: 11, fontWeight: 600 }}>Sensitif</span>
|
||||
<span className="hb-pill hb-pill-warn">Sensitif</span>
|
||||
) : (
|
||||
<span style={{ color: '#666', fontSize: 11 }}>Umum</span>
|
||||
<span className="hb-muted" style={{ fontSize: 11 }}>Umum</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<span style={{ color: responseColor(item.response), fontWeight: 'bold' }}>
|
||||
<td>
|
||||
<span style={{ color: responseColor(item.response), fontWeight: 600, textTransform: 'capitalize' }}>
|
||||
{item.response || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>{item.response_time_seconds ?? '-'}</td>
|
||||
<td style={{ padding: 8 }}>{item.active_session_count}</td>
|
||||
<td style={{ padding: 8, fontSize: 12 }}>{formatDate(item.notified_at)}</td>
|
||||
<td style={{ padding: 8, fontSize: 12 }}>{formatDate(item.responded_at)}</td>
|
||||
<td>{item.response_time_seconds ?? '-'}</td>
|
||||
<td>{item.active_session_count}</td>
|
||||
<td style={{ fontSize: 12 }}>{formatDate(item.notified_at)}</td>
|
||||
<td style={{ fontSize: 12 }}>{formatDate(item.responded_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!logData?.items || logData.items.length === 0) && (
|
||||
<tr><td colSpan={8} style={{ padding: 16, textAlign: 'center', color: '#999' }}>Tidak ada data</td></tr>
|
||||
<tr><td colSpan={8} style={{ padding: 24, textAlign: 'center', color: 'var(--hb-ink-muted)' }}>Tidak ada data</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{logData && logData.total > logLimit && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12, alignItems: 'center' }}>
|
||||
<button disabled={logPage <= 1} onClick={() => setLogPage(p => p - 1)}>Prev</button>
|
||||
<span>Halaman {logData.page} dari {Math.ceil(logData.total / logLimit)}</span>
|
||||
<button disabled={logPage >= Math.ceil(logData.total / logLimit)} onClick={() => setLogPage(p => p + 1)}>Next</button>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 16, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<button className="hb-btn-secondary" disabled={logPage <= 1} onClick={() => setLogPage(p => p - 1)}>Prev</button>
|
||||
<span className="hb-soft" style={{ fontSize: 13 }}>Halaman {logData.page} dari {Math.ceil(logData.total / logLimit)}</span>
|
||||
<button className="hb-btn-secondary" disabled={logPage >= Math.ceil(logData.total / logLimit)} onClick={() => setLogPage(p => p + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -100,37 +100,49 @@ export default function MitrasPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1>Mitra</h1>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h1 style={{ margin: 0 }}>Mitra</h1>
|
||||
<button onClick={() => setShowForm(!showForm)}>+ Tambah Mitra</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); createMutation.mutate(form) }}
|
||||
style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
|
||||
<h3>Tambah Mitra Baru</h3>
|
||||
<input placeholder="Nomor HP (+628...)" value={form.phone}
|
||||
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} required
|
||||
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
|
||||
<input placeholder="Nama" value={form.display_name}
|
||||
onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))} required
|
||||
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); createMutation.mutate(form) }}
|
||||
className="hb-card"
|
||||
style={{ marginTop: 16, marginBottom: 24 }}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>Tambah Mitra Baru</h3>
|
||||
<input
|
||||
placeholder="Nomor HP (+628...)"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
required
|
||||
style={{ marginBottom: 10 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="Nama"
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, display_name: e.target.value }))}
|
||||
required
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
|
||||
{createMutation.isPending ? 'Menyimpan…' : 'Simpan'}
|
||||
</button>
|
||||
{createMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||
{createMutation.isError && <p className="hb-error">Gagal menyimpan.</p>}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nomor HP</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status Akun</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Online</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Sesi Aktif</th>
|
||||
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
|
||||
<th>Nama</th>
|
||||
<th>Nomor HP</th>
|
||||
<th>Status Akun</th>
|
||||
<th>Online</th>
|
||||
<th>Sesi Aktif</th>
|
||||
<th>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -140,7 +152,7 @@ export default function MitrasPage() {
|
||||
return (
|
||||
<Fragment key={mitra.id}>
|
||||
<tr>
|
||||
<td style={{ padding: 8 }}>
|
||||
<td>
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
@@ -151,74 +163,79 @@ export default function MitrasPage() {
|
||||
if (e.key === 'Escape') cancelEdit()
|
||||
}}
|
||||
disabled={nameMutation.isPending}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
mitra.display_name
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>{mitra.phone}</td>
|
||||
<td style={{ padding: 8 }}>{mitra.is_active ? 'Aktif' : 'Nonaktif'}</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<span style={{ color: onlineInfo ? 'green' : 'grey' }}>
|
||||
<td>{mitra.phone}</td>
|
||||
<td>
|
||||
<span className={`hb-pill ${mitra.is_active ? 'hb-pill-success' : 'hb-pill-neutral'}`}>
|
||||
{mitra.is_active ? 'Aktif' : 'Nonaktif'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`hb-pill ${onlineInfo ? 'hb-pill-success' : 'hb-pill-neutral'}`}>
|
||||
{onlineInfo ? '● Online' : '○ Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>{onlineInfo ? onlineInfo.active_session_count : '-'}</td>
|
||||
<td style={{ padding: 8, display: 'flex', gap: 8 }}>
|
||||
<td>{onlineInfo ? onlineInfo.active_session_count : '-'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => saveEdit(mitra.id)}
|
||||
disabled={nameMutation.isPending || !editName.trim() || editName.trim() === mitra.display_name}
|
||||
>
|
||||
{nameMutation.isPending ? 'Menyimpan...' : 'Simpan'}
|
||||
{nameMutation.isPending ? 'Menyimpan…' : 'Simpan'}
|
||||
</button>
|
||||
<button onClick={cancelEdit} disabled={nameMutation.isPending}>Batal</button>
|
||||
<button className="hb-btn-secondary" onClick={cancelEdit} disabled={nameMutation.isPending}>Batal</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={() => startEdit(mitra)}>Edit Nama</button>
|
||||
<button onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}>
|
||||
<button className="hb-btn-ghost" onClick={() => startEdit(mitra)}>Edit Nama</button>
|
||||
<button className="hb-btn-secondary" onClick={() => statusMutation.mutate({ id: mitra.id, is_active: !mitra.is_active })}>
|
||||
{mitra.is_active ? 'Nonaktifkan' : 'Aktifkan'}
|
||||
</button>
|
||||
<button onClick={() => setLogsForMitra(logsForMitra === mitra.id ? null : mitra.id)}>
|
||||
<button className="hb-btn-ghost" onClick={() => setLogsForMitra(logsForMitra === mitra.id ? null : mitra.id)}>
|
||||
{logsForMitra === mitra.id ? 'Tutup Log' : 'Log Online'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{logsForMitra === mitra.id && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: 0, background: '#fafafa', borderBottom: '1px solid #eee' }}>
|
||||
<div style={{ padding: 16 }}>
|
||||
<td colSpan={6} style={{ padding: 0, background: 'var(--hb-brand-softer)' }}>
|
||||
<div style={{ padding: 18 }}>
|
||||
<h4 style={{ margin: '0 0 12px' }}>Log Online/Offline · {mitra.display_name}</h4>
|
||||
{logsLoading ? (
|
||||
<p style={{ margin: 0 }}>Loading...</p>
|
||||
<p className="hb-soft" style={{ margin: 0 }}>Loading…</p>
|
||||
) : logsData?.items?.length ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Waktu</th>
|
||||
<th>Status</th>
|
||||
<th>Waktu</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logsData.items.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ padding: 8 }}>
|
||||
<span style={{ color: log.status === 'online' ? 'green' : 'grey' }}>
|
||||
<td>
|
||||
<span className={`hb-pill ${log.status === 'online' ? 'hb-pill-success' : 'hb-pill-neutral'}`}>
|
||||
{log.status === 'online' ? '● Online' : '○ Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>{new Date(log.timestamp).toLocaleString('id-ID')}</td>
|
||||
<td>{new Date(log.timestamp).toLocaleString('id-ID')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p style={{ margin: 0, color: '#888' }}>Belum ada log.</p>
|
||||
<p className="hb-muted" style={{ margin: 0 }}>Belum ada log.</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -230,5 +247,6 @@ export default function MitrasPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ export default function PaymentCatalogPage() {
|
||||
methodsReorder.mutate(inGroup)
|
||||
}
|
||||
|
||||
if (groupsQ.isLoading || methodsQ.isLoading) return <div>Loading…</div>
|
||||
if (groupsQ.error || methodsQ.error) return <div>Failed to load payment catalog.</div>
|
||||
if (groupsQ.isLoading || methodsQ.isLoading) return <div className="hb-soft">Loading…</div>
|
||||
if (groupsQ.error || methodsQ.error) return <div className="hb-error">Failed to load payment catalog.</div>
|
||||
|
||||
const groups = groupsQ.data ?? []
|
||||
const methods = methodsQ.data ?? []
|
||||
@@ -116,9 +116,9 @@ export default function PaymentCatalogPage() {
|
||||
: methods
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ marginBottom: 8 }}>Payment Catalog</h1>
|
||||
<p style={{ color: '#666', marginBottom: 24, fontSize: 14 }}>
|
||||
<p className="hb-soft" style={{ marginBottom: 24, fontSize: 14 }}>
|
||||
Groups and methods rendered by the customer app on the "cara bayar"
|
||||
screen. Reorder controls the visible order. <code>payment_code</code> must
|
||||
match the Xendit channel code exactly (uppercase, e.g. <code>OVO</code>,
|
||||
@@ -127,12 +127,13 @@ export default function PaymentCatalogPage() {
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
background: '#FFE6E6',
|
||||
border: '1px solid #D86B6B',
|
||||
background: '#FCE9E9',
|
||||
border: '1px solid var(--hb-danger)',
|
||||
color: '#8B2D2D',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
borderRadius: 'var(--hb-radius-md)',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
@@ -161,16 +162,18 @@ export default function PaymentCatalogPage() {
|
||||
{groups.map((g, i) => {
|
||||
const methodsInGroup = methods.filter((m) => m.group_id === g.id).length
|
||||
return (
|
||||
<tr key={g.id} style={selectedGroupId === g.id ? { background: '#FBEFF3' } : undefined}>
|
||||
<tr key={g.id} style={selectedGroupId === g.id ? { background: 'var(--hb-brand-softer)' } : undefined}>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<button disabled={i === 0} onClick={() => moveGroup(g.id, -1)} style={btnStyle('ghost')}>↑</button>
|
||||
<button disabled={i === groups.length - 1} onClick={() => moveGroup(g.id, +1)} style={btnStyle('ghost')}>↓</button>
|
||||
<span style={{ marginLeft: 8, color: '#666' }}>{g.display_order}</span>
|
||||
<span className="hb-muted" style={{ marginLeft: 8 }}>{g.display_order}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={tdStyle}><strong>{g.name}</strong></td>
|
||||
<td style={tdStyle}>{g.is_active ? '✓' : '✗'}</td>
|
||||
<td style={tdStyle}><strong style={{ color: 'var(--hb-ink)' }}>{g.name}</strong></td>
|
||||
<td style={tdStyle}>
|
||||
{g.is_active ? <span className="hb-pill hb-pill-success">Aktif</span> : <span className="hb-pill hb-pill-neutral">Nonaktif</span>}
|
||||
</td>
|
||||
<td style={tdStyle}>{methodsInGroup}</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => setSelectedGroupId(g.id === selectedGroupId ? null : g.id)} style={btnStyle('ghost')}>
|
||||
@@ -191,7 +194,7 @@ export default function PaymentCatalogPage() {
|
||||
)
|
||||
})}
|
||||
{groups.length === 0 && (
|
||||
<tr><td colSpan={5} style={{ ...tdStyle, color: '#999' }}>No groups yet.</td></tr>
|
||||
<tr><td colSpan={5} style={{ ...tdStyle, color: 'var(--hb-ink-muted)', textAlign: 'center', padding: 24 }}>No groups yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -212,6 +215,8 @@ export default function PaymentCatalogPage() {
|
||||
display_name: '',
|
||||
payment_code: '',
|
||||
icon: '',
|
||||
min_amount: '',
|
||||
max_amount: '',
|
||||
display_order: filteredMethods.length,
|
||||
is_active: true,
|
||||
})}
|
||||
@@ -225,7 +230,9 @@ export default function PaymentCatalogPage() {
|
||||
<th style={thStyle}>Group</th>
|
||||
<th style={thStyle}>Display name</th>
|
||||
<th style={thStyle}>Code</th>
|
||||
<th style={thStyle}>Icon slug</th>
|
||||
<th style={thStyle}>Icon slug(s)</th>
|
||||
<th style={thStyle}>Min Rp</th>
|
||||
<th style={thStyle}>Max Rp</th>
|
||||
<th style={thStyle}>Active</th>
|
||||
<th style={thStyle}>Actions</th>
|
||||
</tr>
|
||||
@@ -234,17 +241,21 @@ export default function PaymentCatalogPage() {
|
||||
{filteredMethods.map((m, i, arr) => (
|
||||
<tr key={m.id}>
|
||||
<td style={tdStyle}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<button disabled={i === 0} onClick={() => moveMethod(m.id, -1, m.group_id)} style={btnStyle('ghost')}>↑</button>
|
||||
<button disabled={i === arr.length - 1} onClick={() => moveMethod(m.id, +1, m.group_id)} style={btnStyle('ghost')}>↓</button>
|
||||
<span style={{ marginLeft: 8, color: '#666' }}>{m.display_order}</span>
|
||||
<span className="hb-muted" style={{ marginLeft: 8 }}>{m.display_order}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={tdStyle}>{groups.find((g) => g.id === m.group_id)?.name ?? '—'}</td>
|
||||
<td style={tdStyle}><strong>{m.display_name}</strong></td>
|
||||
<td style={tdStyle}><code>{m.payment_code}</code></td>
|
||||
<td style={tdStyle}>{m.icon || <span style={{ color: '#999' }}>—</span>}</td>
|
||||
<td style={tdStyle}>{m.is_active ? '✓' : '✗'}</td>
|
||||
<td style={tdStyle}><strong style={{ color: 'var(--hb-ink)' }}>{m.display_name}</strong></td>
|
||||
<td style={tdStyle}><code style={{ background: 'var(--hb-brand-softer)', padding: '2px 6px', borderRadius: 6, color: 'var(--hb-brand-dark)' }}>{m.payment_code}</code></td>
|
||||
<td style={tdStyle}>{m.icon || <span className="hb-muted">—</span>}</td>
|
||||
<td style={tdStyle}>{m.min_amount != null ? m.min_amount.toLocaleString('id-ID') : <span className="hb-muted">—</span>}</td>
|
||||
<td style={tdStyle}>{m.max_amount != null ? m.max_amount.toLocaleString('id-ID') : <span className="hb-muted">—</span>}</td>
|
||||
<td style={tdStyle}>
|
||||
{m.is_active ? <span className="hb-pill hb-pill-success">Aktif</span> : <span className="hb-pill hb-pill-neutral">Nonaktif</span>}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<button onClick={() => setMethodForm(m)} style={btnStyle('ghost')}>Edit</button>
|
||||
<button
|
||||
@@ -258,7 +269,7 @@ export default function PaymentCatalogPage() {
|
||||
</tr>
|
||||
))}
|
||||
{filteredMethods.length === 0 && (
|
||||
<tr><td colSpan={7} style={{ ...tdStyle, color: '#999' }}>
|
||||
<tr><td colSpan={9} style={{ ...tdStyle, color: 'var(--hb-ink-muted)', textAlign: 'center', padding: 24 }}>
|
||||
{selectedGroupId ? 'No methods in this group yet.' : 'No methods yet.'}
|
||||
</td></tr>
|
||||
)}
|
||||
@@ -300,6 +311,15 @@ export default function PaymentCatalogPage() {
|
||||
payment_code: methodForm.payment_code?.trim()?.toUpperCase(),
|
||||
display_order: Number(methodForm.display_order) || 0,
|
||||
icon: methodForm.icon?.trim() || null,
|
||||
// Empty string → null (no bound). Other input goes through
|
||||
// Number(), which leaves NaN for non-numeric — the server then
|
||||
// rejects with VALIDATION.
|
||||
min_amount: methodForm.min_amount === '' || methodForm.min_amount == null
|
||||
? null
|
||||
: Number(methodForm.min_amount),
|
||||
max_amount: methodForm.max_amount === '' || methodForm.max_amount == null
|
||||
? null
|
||||
: Number(methodForm.max_amount),
|
||||
is_active: !!methodForm.is_active,
|
||||
}
|
||||
if (methodForm.id) {
|
||||
@@ -370,11 +390,33 @@ const MethodModal = ({ form, groups, onChange, onSubmit, onClose }) => (
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Icon slug (file in client_app/assets/payment_icons/)">
|
||||
<Field label="Icon slugs (comma-separated, idn-finlogos asset names — served by backend)">
|
||||
<input
|
||||
value={form.icon ?? ''}
|
||||
onChange={(e) => onChange({ ...form, icon: e.target.value })}
|
||||
placeholder="ovo, dana, qris, …"
|
||||
placeholder="ovo-new OR visa,mastercard,jcb"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Minimum amount (Rp, inclusive, empty = no minimum)">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={form.min_amount ?? ''}
|
||||
onChange={(e) => onChange({ ...form, min_amount: e.target.value })}
|
||||
placeholder="e.g. 10000"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Maximum amount (Rp, inclusive, empty = no maximum)">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={form.max_amount ?? ''}
|
||||
onChange={(e) => onChange({ ...form, max_amount: e.target.value })}
|
||||
placeholder="e.g. 10000000"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Field>
|
||||
@@ -399,11 +441,19 @@ const MethodModal = ({ form, groups, onChange, onSubmit, onClose }) => (
|
||||
|
||||
const Modal = ({ title, onClose, children }) => (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
|
||||
position: 'fixed', inset: 0, background: 'rgba(42, 24, 32, 0.45)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
|
||||
padding: 24,
|
||||
}} onClick={onClose}>
|
||||
<div
|
||||
style={{ background: 'white', padding: 24, borderRadius: 12, minWidth: 420, maxWidth: 560 }}
|
||||
className="hb-card"
|
||||
style={{
|
||||
padding: 28,
|
||||
minWidth: 420,
|
||||
maxWidth: 560,
|
||||
width: '100%',
|
||||
boxShadow: 'var(--hb-shadow-card)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>{title}</h3>
|
||||
@@ -413,37 +463,79 @@ const Modal = ({ title, onClose, children }) => (
|
||||
)
|
||||
|
||||
const Field = ({ label, children }) => (
|
||||
<div style={{ marginBottom: 12, display: 'flex', flexDirection: 'column' }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: '#444', marginBottom: 4 }}>{label}</label>
|
||||
<div style={{ marginBottom: 14, display: 'flex', flexDirection: 'column' }}>
|
||||
<label style={{ marginBottom: 6 }}>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const Footer = ({ onClose, onSubmit }) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
||||
<button onClick={onClose} style={btnStyle('ghost')}>Cancel</button>
|
||||
<button onClick={onSubmit} style={btnStyle('primary')}>Save</button>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20 }}>
|
||||
<button onClick={onClose} className="hb-btn-secondary">Cancel</button>
|
||||
<button onClick={onSubmit}>Save</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ─────────────────────────── inline styles ───────────────────────────
|
||||
// Use design tokens — defaults defined in src/theme/global.css.
|
||||
|
||||
const tableStyle = { width: '100%', borderCollapse: 'collapse', background: 'white', borderRadius: 8, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }
|
||||
const thStyle = { textAlign: 'left', padding: 10, fontSize: 12, fontWeight: 600, color: '#444', background: '#FAFAFA', borderBottom: '1px solid #EEE' }
|
||||
const tdStyle = { padding: 10, fontSize: 13, borderBottom: '1px solid #F2F2F2' }
|
||||
const inputStyle = { padding: '8px 10px', borderRadius: 6, border: '1px solid #DDD', fontSize: 14 }
|
||||
|
||||
const btnStyle = (variant) => ({
|
||||
padding: variant === 'ghost' ? '4px 8px' : '6px 12px',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
marginRight: 4,
|
||||
const tableStyle = {
|
||||
width: '100%',
|
||||
borderCollapse: 'separate',
|
||||
borderSpacing: 0,
|
||||
background: 'var(--hb-surface)',
|
||||
borderRadius: 'var(--hb-radius-lg)',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'var(--hb-shadow-soft)',
|
||||
}
|
||||
const thStyle = {
|
||||
textAlign: 'left',
|
||||
padding: '12px 14px',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
...(variant === 'primary'
|
||||
? { background: '#E17A9D', color: 'white' }
|
||||
: variant === 'danger'
|
||||
? { background: '#fff', color: '#D86B6B', border: '1px solid #D86B6B' }
|
||||
: { background: '#F0F0F0', color: '#333' }),
|
||||
})
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--hb-font-display)',
|
||||
color: 'var(--hb-brand-dark)',
|
||||
background: 'var(--hb-brand-softer)',
|
||||
borderBottom: '1px solid var(--hb-border)',
|
||||
letterSpacing: '-0.005em',
|
||||
}
|
||||
const tdStyle = {
|
||||
padding: '12px 14px',
|
||||
fontSize: 13.5,
|
||||
color: 'var(--hb-ink)',
|
||||
borderBottom: '1px solid var(--hb-border)',
|
||||
verticalAlign: 'top',
|
||||
}
|
||||
const inputStyle = {
|
||||
padding: '10px 14px',
|
||||
borderRadius: 'var(--hb-radius-md)',
|
||||
border: '1.5px solid var(--hb-border)',
|
||||
fontSize: 14,
|
||||
fontFamily: 'var(--hb-font-body)',
|
||||
color: 'var(--hb-ink)',
|
||||
background: 'var(--hb-surface)',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
const btnStyle = (variant) => {
|
||||
const base = {
|
||||
padding: variant === 'ghost' ? '6px 10px' : '8px 14px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--hb-radius-pill)',
|
||||
cursor: 'pointer',
|
||||
marginRight: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--hb-font-body)',
|
||||
minHeight: variant === 'ghost' ? 30 : 34,
|
||||
transition: 'background 120ms ease',
|
||||
boxShadow: variant === 'primary' ? 'var(--hb-shadow-button)' : 'none',
|
||||
}
|
||||
if (variant === 'primary') {
|
||||
return { ...base, background: 'var(--hb-brand)', color: '#fff' }
|
||||
}
|
||||
if (variant === 'danger') {
|
||||
return { ...base, background: 'var(--hb-surface)', color: 'var(--hb-danger)', border: '1.5px solid var(--hb-danger)' }
|
||||
}
|
||||
return { ...base, background: 'var(--hb-brand-softer)', color: 'var(--hb-brand-dark)' }
|
||||
}
|
||||
|
||||
@@ -112,37 +112,35 @@ export default function SessionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Customer</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Mitra</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Status</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Topik</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Waktu</th>
|
||||
<th style={{ padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
|
||||
<th>Customer</th>
|
||||
<th>Mitra</th>
|
||||
<th>Status</th>
|
||||
<th>Topik</th>
|
||||
<th>Waktu</th>
|
||||
<th>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.items?.map((session) => (
|
||||
<React.Fragment key={session.id}>
|
||||
<tr>
|
||||
<td style={{ padding: 8 }}>{session.customer_display_name}</td>
|
||||
<td style={{ padding: 8 }}>{session.mitra_display_name ?? '-'}</td>
|
||||
<td style={{ padding: 8 }}>{session.status}</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<td>{session.customer_display_name}</td>
|
||||
<td>{session.mitra_display_name ?? '-'}</td>
|
||||
<td><span className="hb-pill hb-pill-neutral">{session.status}</span></td>
|
||||
<td>
|
||||
{session.topic_sensitivity === 'sensitive' ? (
|
||||
<span style={{ background: '#F7B500', color: '#3D2A00', padding: '2px 8px', borderRadius: 999, fontSize: 12, fontWeight: 600 }}>
|
||||
Sensitif
|
||||
</span>
|
||||
<span className="hb-pill hb-pill-warn">Sensitif</span>
|
||||
) : (
|
||||
<span style={{ color: '#666', fontSize: 12 }}>Umum</span>
|
||||
<span className="hb-muted" style={{ fontSize: 12 }}>Umum</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>{new Date(session.created_at).toLocaleString('id-ID')}</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => toggleExpand(session.id)} style={{ fontSize: 12 }}>
|
||||
<td>{new Date(session.created_at).toLocaleString('id-ID')}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button className="hb-btn-ghost" onClick={() => toggleExpand(session.id)} style={{ fontSize: 12, minHeight: 32, padding: '6px 12px' }}>
|
||||
{expandedId === session.id ? 'Tutup' : 'Detail'}
|
||||
</button>
|
||||
{['active', 'pending_payment'].includes(session.status) && (
|
||||
@@ -150,20 +148,21 @@ export default function SessionsPage() {
|
||||
<select
|
||||
value={rerouteTarget[session.id] ?? ''}
|
||||
onChange={e => setRerouteTarget(t => ({ ...t, [session.id]: e.target.value }))}
|
||||
style={{ fontSize: 12 }}
|
||||
style={{ fontSize: 12, padding: '6px 10px', width: 'auto' }}
|
||||
>
|
||||
<option value="">Reroute ke...</option>
|
||||
<option value="">Reroute ke…</option>
|
||||
{(onlineMitras ?? [])
|
||||
.filter(m => m.id !== session.mitra_id)
|
||||
.map(m => <option key={m.id} value={m.id}>{m.display_name}</option>)}
|
||||
</select>
|
||||
<button
|
||||
className="hb-btn-secondary"
|
||||
disabled={!rerouteTarget[session.id] || rerouteMutation.isPending}
|
||||
onClick={() => rerouteMutation.mutate({
|
||||
sessionId: session.id,
|
||||
new_mitra_id: rerouteTarget[session.id],
|
||||
})}
|
||||
style={{ fontSize: 12 }}
|
||||
style={{ fontSize: 12, minHeight: 32, padding: '6px 12px' }}
|
||||
>
|
||||
Reroute
|
||||
</button>
|
||||
@@ -174,14 +173,14 @@ export default function SessionsPage() {
|
||||
</tr>
|
||||
{expandedId === session.id && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ padding: 16, background: '#fafafa' }}>
|
||||
{detailLoading && <div>Memuat detail…</div>}
|
||||
{detail?.error && <div style={{ color: 'red' }}>Gagal memuat detail sesi.</div>}
|
||||
<td colSpan={6} style={{ background: 'var(--hb-brand-softer)', padding: 18 }}>
|
||||
{detailLoading && <div className="hb-soft">Memuat detail…</div>}
|
||||
{detail?.error && <div className="hb-error">Gagal memuat detail sesi.</div>}
|
||||
{detail && !detail.error && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 8px' }}>Riwayat Topik Sensitif</h3>
|
||||
{(!detail.sensitivity_log || detail.sensitivity_log.length === 0) ? (
|
||||
<p style={{ color: '#666', fontSize: 13 }}>Belum ada perubahan topik oleh Mitra.</p>
|
||||
<p className="hb-soft" style={{ fontSize: 13 }}>Belum ada perubahan topik oleh Mitra.</p>
|
||||
) : (
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{detail.sensitivity_log.map(log => (
|
||||
@@ -204,14 +203,14 @@ export default function SessionsPage() {
|
||||
</table>
|
||||
|
||||
{data && (
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</button>
|
||||
<span>Halaman {data.page} dari {Math.ceil(data.total / data.limit) || 1}</span>
|
||||
<button disabled={page >= Math.ceil(data.total / data.limit)} onClick={() => setPage(p => p + 1)}>Next</button>
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 12, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<button className="hb-btn-secondary" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</button>
|
||||
<span className="hb-soft" style={{ fontSize: 13 }}>Halaman {data.page} dari {Math.ceil(data.total / data.limit) || 1}</span>
|
||||
<button className="hb-btn-secondary" disabled={page >= Math.ceil(data.total / data.limit)} onClick={() => setPage(p => p + 1)}>Next</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rerouteMutation.isError && <p style={{ color: 'red', marginTop: 8 }}>Gagal reroute sesi.</p>}
|
||||
{rerouteMutation.isError && <p className="hb-error" style={{ marginTop: 8 }}>Gagal reroute sesi.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,22 +56,22 @@ const ResetPasswordRow = ({ userId }) => {
|
||||
})
|
||||
|
||||
if (!open) {
|
||||
return <button onClick={() => { setOpen(true); setSuccess(false); setError('') }}>Reset password</button>
|
||||
return <button className="hb-btn-secondary" onClick={() => { setOpen(true); setSuccess(false); setError('') }}>Reset password</button>
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); setError(''); mutation.mutate({ id: userId, new_password: pw }) }}
|
||||
style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
|
||||
style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input type="text" placeholder="Password baru" value={pw}
|
||||
onChange={e => setPw(e.target.value)} required minLength={8}
|
||||
style={{ width: 180 }} />
|
||||
<button type="button" onClick={() => setPw(generateTempPassword())}>Generate</button>
|
||||
<button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '...' : 'Simpan'}
|
||||
style={{ width: 200, padding: '8px 12px' }} />
|
||||
<button type="button" className="hb-btn-ghost" onClick={() => setPw(generateTempPassword())} style={{ minHeight: 36, padding: '6px 12px', fontSize: 12 }}>Generate</button>
|
||||
<button type="submit" disabled={mutation.isPending} style={{ minHeight: 36, padding: '6px 14px', fontSize: 12 }}>
|
||||
{mutation.isPending ? '…' : 'Simpan'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setOpen(false); setError(''); setSuccess(false) }}>Batal</button>
|
||||
{error && <span style={{ color: 'red', fontSize: 12 }}>{error}</span>}
|
||||
{success && <span style={{ color: 'green', fontSize: 12 }}>Tersimpan.</span>}
|
||||
<button type="button" className="hb-btn-secondary" onClick={() => { setOpen(false); setError(''); setSuccess(false) }} style={{ minHeight: 36, padding: '6px 12px', fontSize: 12 }}>Batal</button>
|
||||
{error && <span className="hb-error" style={{ fontSize: 12, margin: 0 }}>{error}</span>}
|
||||
{success && <span className="hb-success" style={{ fontSize: 12, margin: 0 }}>Tersimpan.</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -100,61 +100,66 @@ export default function UsersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1>Control Center Users</h1>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h1 style={{ margin: 0 }}>Control Center Users</h1>
|
||||
<button onClick={() => { setShowForm(!showForm); setCreateError('') }}>+ Tambah User</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); setCreateError(''); createMutation.mutate(form) }}
|
||||
style={{ marginBottom: 24, padding: 16, border: '1px solid #eee' }}>
|
||||
<h3>Tambah User Baru</h3>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); setCreateError(''); createMutation.mutate(form) }}
|
||||
className="hb-card"
|
||||
style={{ marginTop: 16, marginBottom: 24 }}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>Tambah User Baru</h3>
|
||||
<input placeholder="Email" type="email" value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} required
|
||||
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
|
||||
style={{ marginBottom: 10 }} />
|
||||
<input placeholder="Nama" value={form.display_name}
|
||||
onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))} required
|
||||
style={{ display: 'block', marginBottom: 8, width: '100%' }} />
|
||||
style={{ marginBottom: 10 }} />
|
||||
<select value={form.role_id} onChange={e => setForm(f => ({ ...f, role_id: e.target.value }))} required
|
||||
style={{ display: 'block', marginBottom: 8, width: '100%' }}>
|
||||
style={{ marginBottom: 10 }}>
|
||||
<option value="">Pilih Role</option>
|
||||
{roles?.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<input placeholder="Password awal (min 8, huruf besar/kecil + angka)" type="text" value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} required minLength={8}
|
||||
style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => setForm(f => ({ ...f, password: generateTempPassword() }))}>
|
||||
<button type="button" className="hb-btn-ghost" onClick={() => setForm(f => ({ ...f, password: generateTempPassword() }))}>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Menyimpan...' : 'Simpan'}
|
||||
{createMutation.isPending ? 'Menyimpan…' : 'Simpan'}
|
||||
</button>
|
||||
{createError && <p style={{ color: 'red' }}>{createError}</p>}
|
||||
{createError && <p className="hb-error">{createError}</p>}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Nama</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Email</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Role</th>
|
||||
<th style={{ textAlign: 'left', padding: 8, borderBottom: '1px solid #eee' }}>Aksi</th>
|
||||
<th>Nama</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.items?.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td style={{ padding: 8 }}>{user.display_name}</td>
|
||||
<td style={{ padding: 8 }}>{user.email}</td>
|
||||
<td style={{ padding: 8 }}>{user.role?.name}</td>
|
||||
<td style={{ padding: 8 }}><ResetPasswordRow userId={user.id} /></td>
|
||||
<td>{user.display_name}</td>
|
||||
<td>{user.email}</td>
|
||||
<td><span className="hb-pill">{user.role?.name}</span></td>
|
||||
<td><ResetPasswordRow userId={user.id} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
263
control_center/src/theme/global.css
Normal file
@@ -0,0 +1,263 @@
|
||||
/* Halo Bestie Control Center — design system applied as global CSS.
|
||||
Tokens mirror src/theme/tokens.js. Element-level defaults let existing
|
||||
pages inherit the warm theme without per-file rewrites.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--hb-bg: #FDF7F4;
|
||||
--hb-surface: #FFFFFF;
|
||||
--hb-ink: #2A1820;
|
||||
--hb-ink-soft: #6B5560;
|
||||
--hb-ink-muted: #9C8590;
|
||||
--hb-brand: #E17A9D;
|
||||
--hb-brand-dark: #8C3255;
|
||||
--hb-brand-soft: #F7E4E9;
|
||||
--hb-brand-softer: #FBEFF3;
|
||||
--hb-accent: #F7B26A;
|
||||
--hb-accent-soft: #FCEAD3;
|
||||
--hb-mint: #B8DBC8;
|
||||
--hb-lilac: #D4C5E8;
|
||||
--hb-success: #5BA67F;
|
||||
--hb-danger: #D86B6B;
|
||||
--hb-border: #F0E4E8;
|
||||
|
||||
--hb-radius-sm: 8px;
|
||||
--hb-radius-md: 12px;
|
||||
--hb-radius-lg: 16px;
|
||||
--hb-radius-xl: 22px;
|
||||
--hb-radius-pill: 9999px;
|
||||
|
||||
--hb-shadow-soft: 0 1px 2px rgba(140, 50, 85, 0.04), 0 8px 24px rgba(140, 50, 85, 0.06);
|
||||
--hb-shadow-card: 0 2px 6px rgba(140, 50, 85, 0.05), 0 18px 40px rgba(140, 50, 85, 0.10);
|
||||
--hb-shadow-button: 0 4px 14px rgba(225, 122, 157, 0.35);
|
||||
|
||||
--hb-font-display: "Bricolage Grotesque", "Poppins", system-ui, sans-serif;
|
||||
--hb-font-body: "Poppins", "Inter", system-ui, sans-serif;
|
||||
--hb-font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--hb-bg);
|
||||
color: var(--hb-ink);
|
||||
font-family: var(--hb-font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.005em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root { min-height: 100vh; }
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-family: var(--hb-font-display);
|
||||
color: var(--hb-ink);
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1 { font-size: 28px; line-height: 1.2; }
|
||||
h2 { font-size: 20px; line-height: 1.3; }
|
||||
h3 { font-size: 17px; line-height: 1.35; }
|
||||
h4 { font-size: 15px; line-height: 1.4; }
|
||||
|
||||
p { margin: 0 0 12px; color: var(--hb-ink-soft); }
|
||||
|
||||
a {
|
||||
color: var(--hb-brand-dark);
|
||||
text-decoration: none;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
a:hover { color: var(--hb-brand); }
|
||||
|
||||
code, pre { font-family: var(--hb-font-mono); }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
button {
|
||||
font-family: var(--hb-font-body);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
padding: 10px 18px;
|
||||
min-height: 40px;
|
||||
border-radius: var(--hb-radius-pill);
|
||||
border: none;
|
||||
background: var(--hb-brand);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--hb-shadow-button);
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--hb-brand-dark); }
|
||||
button:active:not(:disabled) { transform: scale(0.98); }
|
||||
button:disabled, button[disabled] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Secondary / ghost buttons opt in via class */
|
||||
button.hb-btn-secondary {
|
||||
background: var(--hb-surface);
|
||||
color: var(--hb-brand-dark);
|
||||
border: 1.5px solid var(--hb-brand);
|
||||
box-shadow: none;
|
||||
}
|
||||
button.hb-btn-secondary:hover:not(:disabled) {
|
||||
background: var(--hb-brand-softer);
|
||||
color: var(--hb-brand-dark);
|
||||
}
|
||||
button.hb-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--hb-brand-dark);
|
||||
box-shadow: none;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
button.hb-btn-ghost:hover:not(:disabled) { background: var(--hb-brand-softer); }
|
||||
|
||||
button.hb-btn-danger {
|
||||
background: var(--hb-danger);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(216, 107, 107, 0.32);
|
||||
}
|
||||
button.hb-btn-danger:hover:not(:disabled) { background: #B45656; }
|
||||
|
||||
button.hb-btn-dark {
|
||||
background: var(--hb-ink);
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 18px rgba(42, 24, 32, 0.25);
|
||||
}
|
||||
|
||||
/* ---------- form controls ---------- */
|
||||
input, select, textarea {
|
||||
font-family: var(--hb-font-body);
|
||||
font-size: 14px;
|
||||
color: var(--hb-ink);
|
||||
background: var(--hb-surface);
|
||||
border: 1.5px solid var(--hb-border);
|
||||
border-radius: var(--hb-radius-md);
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--hb-brand);
|
||||
box-shadow: 0 0 0 3px var(--hb-brand-softer);
|
||||
}
|
||||
input[type="checkbox"], input[type="radio"] {
|
||||
width: auto;
|
||||
accent-color: var(--hb-brand);
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="checkbox"]:focus, input[type="radio"]:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--hb-ink-soft);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background: var(--hb-surface);
|
||||
border-radius: var(--hb-radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--hb-shadow-soft);
|
||||
}
|
||||
thead {
|
||||
background: var(--hb-brand-softer);
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
font-family: var(--hb-font-display);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--hb-brand-dark);
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--hb-border);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
td {
|
||||
padding: 12px 14px;
|
||||
color: var(--hb-ink);
|
||||
border-bottom: 1px solid var(--hb-border);
|
||||
font-size: 13.5px;
|
||||
vertical-align: top;
|
||||
}
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover { background: var(--hb-brand-softer); }
|
||||
|
||||
/* ---------- cards / helpers ---------- */
|
||||
.hb-card {
|
||||
background: var(--hb-surface);
|
||||
border-radius: var(--hb-radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: var(--hb-shadow-soft);
|
||||
}
|
||||
.hb-card-soft {
|
||||
background: var(--hb-brand-softer);
|
||||
border-radius: var(--hb-radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hb-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--hb-radius-pill);
|
||||
background: var(--hb-brand-soft);
|
||||
color: var(--hb-brand-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hb-pill-success { background: #DDEFE3; color: #2F7A53; }
|
||||
.hb-pill-warn { background: var(--hb-accent-soft); color: #8A5A20; }
|
||||
.hb-pill-danger { background: #F7D8D8; color: #8A3838; }
|
||||
.hb-pill-neutral { background: var(--hb-border); color: var(--hb-ink-soft); }
|
||||
|
||||
/* Used by Layout side nav */
|
||||
.hb-nav-link {
|
||||
display: block;
|
||||
padding: 9px 14px;
|
||||
border-radius: var(--hb-radius-pill);
|
||||
color: var(--hb-ink-soft);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.hb-nav-link:hover { background: var(--hb-brand-softer); color: var(--hb-brand-dark); }
|
||||
.hb-nav-link.active {
|
||||
background: var(--hb-brand);
|
||||
color: #fff;
|
||||
box-shadow: var(--hb-shadow-button);
|
||||
}
|
||||
.hb-nav-link.active:hover { background: var(--hb-brand-dark); color: #fff; }
|
||||
|
||||
/* small badges / muted text */
|
||||
.hb-muted { color: var(--hb-ink-muted); }
|
||||
.hb-soft { color: var(--hb-ink-soft); }
|
||||
|
||||
/* form layouts */
|
||||
.hb-form-row { margin-bottom: 12px; }
|
||||
.hb-form-row label { display: block; }
|
||||
|
||||
/* error / success text */
|
||||
.hb-error { color: var(--hb-danger); font-size: 13px; margin: 4px 0; }
|
||||
.hb-success { color: var(--hb-success); font-size: 13px; margin: 4px 0; }
|
||||
37
control_center/src/theme/tokens.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Halo Bestie design tokens — ported from mitra_app/figma-bestie/project/screens/tokens.jsx
|
||||
// Control center uses the "warm" palette only (no theme switcher needed for an internal tool).
|
||||
|
||||
export const HB_TOKENS = {
|
||||
palette: {
|
||||
bg: '#FDF7F4',
|
||||
surface: '#FFFFFF',
|
||||
ink: '#2A1820',
|
||||
inkSoft: '#6B5560',
|
||||
inkMuted: '#9C8590',
|
||||
brand: '#E17A9D',
|
||||
brandDark: '#8C3255',
|
||||
brandSoft: '#F7E4E9',
|
||||
brandSofter: '#FBEFF3',
|
||||
accent: '#F7B26A',
|
||||
accentSoft: '#FCEAD3',
|
||||
mint: '#B8DBC8',
|
||||
lilac: '#D4C5E8',
|
||||
success: '#5BA67F',
|
||||
danger: '#D86B6B',
|
||||
border: '#F0E4E8',
|
||||
},
|
||||
type: {
|
||||
display: '"Bricolage Grotesque", "Poppins", system-ui, sans-serif',
|
||||
body: '"Poppins", "Inter", system-ui, sans-serif',
|
||||
mono: '"JetBrains Mono", ui-monospace, monospace',
|
||||
},
|
||||
radius: { sm: 8, md: 12, lg: 16, xl: 22, pill: 9999 },
|
||||
shadow: {
|
||||
soft: '0 1px 2px rgba(140,50,85,0.04), 0 8px 24px rgba(140,50,85,0.06)',
|
||||
card: '0 2px 6px rgba(140,50,85,0.05), 0 18px 40px rgba(140,50,85,0.10)',
|
||||
button: '0 4px 14px rgba(225,122,157,0.35)',
|
||||
inner: 'inset 0 1px 0 rgba(255,255,255,0.6)',
|
||||
},
|
||||
}
|
||||
|
||||
export const t = HB_TOKENS.palette
|
||||
223
requirement/phase5-payment-revamp-2026-05-27.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Phase 5.x — Payment catalog + Xendit prep revamp (2026-05-27)
|
||||
|
||||
> Status: **shipped 2026-05-27** as Stage-8 prep. `XENDIT_ENABLED` still `false`.
|
||||
> See [[project-2026-05-27-payment-xendit-prep]] memory note for runtime status,
|
||||
> and [phase5-xendit-plan.md](phase5-xendit-plan.md) for the umbrella Xendit work.
|
||||
> This doc extends [phase5-payment-catalog-plan.md](phase5-payment-catalog-plan.md)
|
||||
> (the original catalog work landed 2026-05-26 with bundled icons + best-guess codes;
|
||||
> this revamp corrects both and adds amount bounds).
|
||||
|
||||
## Goal
|
||||
|
||||
Get the payment-method catalog into a Xendit-faithful, app-release-free shape so we
|
||||
can flip `XENDIT_ENABLED=true` without touching the mobile build. Three lifts:
|
||||
|
||||
1. **Backend-served icons** so adding a new method doesn't require an app release.
|
||||
2. **Authoritative Xendit channel codes** so `createInvoice` doesn't 400 on first call.
|
||||
3. **Per-method amount bounds** so the picker greys out methods the bill misses and
|
||||
the backend rejects out-of-range early.
|
||||
|
||||
Plus customer-redirect HTML pages + `halobestie://` deeplink so the post-payment UX
|
||||
brings the customer back into the app.
|
||||
|
||||
---
|
||||
|
||||
## 1. Icon hosting (backend wraps `idn-finlogos` npm)
|
||||
|
||||
Replaces the 10 bundled SVGs + `_kBundledSlugs` allowlist that shipped 2026-05-26.
|
||||
|
||||
**Why:** old model required an app release + asset copy for every new icon. New
|
||||
model: backend owns all 572 idn-finlogos brand SVGs; mobile fetches + caches.
|
||||
|
||||
- Backend: `npm i idn-finlogos@2.3.0` (27 MB on disk, zero runtime deps, 572 SVGs)
|
||||
- New service [payment-icon.service.js](../backend/src/services/payment-icon.service.js)
|
||||
loads the slug set from `dist/icons/*.svg` once at boot, exposes
|
||||
`hasIconSlug(slug)` + `resolveIconPath(slug)` + `listIconSlugs()`.
|
||||
- New public route [shared.payment-icons.routes.js](../backend/src/routes/public/shared.payment-icons.routes.js):
|
||||
`GET /assets/payment-icons/:slug.svg`
|
||||
- Validates slug against a path-traversal regex + the boot-time set
|
||||
- Streams the file with `Content-Type: image/svg+xml` and
|
||||
`Cache-Control: public, max-age=31536000, immutable` (1 year — content is
|
||||
stable per `idn-finlogos` version)
|
||||
- 404 on unknown slug (so CC typos surface during testing, not silently)
|
||||
- New internal route [payment-icons.routes.js](../backend/src/routes/internal/payment-icons.routes.js):
|
||||
`GET /internal/payment-icons → { slugs: [...] }` for a future CC dropdown
|
||||
populated from a known set. Not wired into the CC UI yet — operator still
|
||||
types slug as free text.
|
||||
- Mobile: `flutter_cache_manager: ^3.3.1` added. New `PaymentIcon` widget
|
||||
uses `DefaultCacheManager().getSingleFile(url)` + `SvgPicture.file`, falls
|
||||
back to bundled `assets/payment_icons/placeholder.svg` while loading or when
|
||||
URL is null. Deleted the 10 bundled brand SVGs; only `placeholder.svg` stays.
|
||||
|
||||
**License:** idn-finlogos `LICENSE-ASSETS` is CC-BY-NC-4.0 + per-brand
|
||||
permission. Same legal posture as before — centralized on backend now (one
|
||||
NOTICE file to maintain instead of one in the app bundle).
|
||||
|
||||
## 2. Multi-icon support
|
||||
|
||||
The Kartu Kredit row visually represents Visa + Mastercard + JCB on a single
|
||||
tile. The `payment_methods.icon` column is now a comma-separated slug list:
|
||||
|
||||
- Single-icon methods: `icon = 'ovo-new'` (1 slug)
|
||||
- Multi-icon methods: `icon = 'visa,mastercard,jcb'`
|
||||
|
||||
Backend parses the CSV in `buildCatalogFromDb` and emits `icon_urls: string[]`
|
||||
(replacing the singular `icon_url` shipped earlier today). The Flutter
|
||||
`_MethodIconBox` widget renders:
|
||||
- 40×40 container with one 22px icon (single) OR
|
||||
- Auto-width container with 18px icons + 3px gaps (multi)
|
||||
|
||||
Catalog cache key bumped `v3 → v4` to avoid serving stale-shape entries from
|
||||
Valkey for up to `VALKEY_TTL_SECONDS` after deploy.
|
||||
|
||||
## 3. Xendit-accurate channel codes
|
||||
|
||||
The 2026-05-26 seed used `BCA_VA`, `MANDIRI_VA`, etc. and `CREDIT_CARD` — all
|
||||
pattern-matched from other PG APIs, none of which are what Xendit's Invoice
|
||||
API actually accepts. Codes verified against
|
||||
https://docs.xendit.co/docs/available-payment-channels:
|
||||
|
||||
| Domain | Xendit Invoice code |
|
||||
|---|---|
|
||||
| Bank VA | `<BANK>_VIRTUAL_ACCOUNT` (`BCA_VIRTUAL_ACCOUNT`, etc.) |
|
||||
| E-Wallet | `OVO`, `DANA`, `SHOPEEPAY`, `LINKAJA`, `ASTRAPAY` |
|
||||
| Outlet | `ALFAMART`, `INDOMARET` |
|
||||
| QRIS | `QRIS` |
|
||||
| Cards | `CARDS` (single channel covers Visa/MC/JCB/Amex) |
|
||||
|
||||
Also: the original icon slugs `ovo` and `shopeepay` don't exist in
|
||||
idn-finlogos — correct slugs are `ovo-new` and `shopee-pay`. See
|
||||
[[feedback-xendit-invoice-channel-codes]] for the full slug-vs-code reference.
|
||||
|
||||
## 4. Per-method amount bounds
|
||||
|
||||
Two new BIGINT columns on `payment_methods`:
|
||||
- `min_amount` — inclusive Rupiah floor
|
||||
- `max_amount` — inclusive Rupiah ceiling
|
||||
|
||||
Both nullable (null = no bound). BIGINT because Xendit documents some maxes at
|
||||
Rp 50,000,000,000 (50B) — past INT range.
|
||||
|
||||
**UX:** picker tile renders at 50% opacity with subtitle `"min Rp 10.000"` or
|
||||
`"maks Rp 1.000.000"` when the current bill misses the bound. Tap is no-op.
|
||||
|
||||
**Backend gate:** [client.payment.routes.js](../backend/src/routes/public/client.payment.routes.js)
|
||||
rejects with `INVALID_PAYMENT_AMOUNT` (422) when `amount < min` or `amount > max`,
|
||||
returning `{ amount, min_amount, max_amount }` in `details`. Defense in depth
|
||||
against a stale-catalog client.
|
||||
|
||||
## 5. Re-runnable seed
|
||||
|
||||
Old gate: `if (groupCount === 0)` — ran once, never again. Once seeded, the
|
||||
operator was on their own.
|
||||
|
||||
New: every migrate run executes the seed; groups upsert via `ON CONFLICT (name)
|
||||
DO NOTHING` (needs new unique index on `payment_method_groups.name`); methods
|
||||
via `ON CONFLICT (payment_code) DO NOTHING`. Operator edits in CC are never
|
||||
clobbered. New methods added to the seed list later land on the next migration;
|
||||
existing rows aren't touched.
|
||||
|
||||
For fully clean state (one-time wipe) use [reset-payment-catalog.js](../backend/.dev/reset-payment-catalog.js):
|
||||
|
||||
```
|
||||
cd backend
|
||||
node .dev/reset-payment-catalog.js
|
||||
node src/db/migrate.js
|
||||
node .dev/inspect-payment-catalog.js # sanity dump
|
||||
```
|
||||
|
||||
## 6. Seeded catalog (18 methods, 5 groups)
|
||||
|
||||
All values verified from Xendit docs 2026-05-27.
|
||||
|
||||
| Group | Display | payment_code | icon | min Rp | max Rp |
|
||||
|---|---|---|---|---|---|
|
||||
| Paling Cepat | QRIS | `QRIS` | `qris` | 1.500 | 20.000.000 |
|
||||
| E-Wallet | OVO | `OVO` | `ovo-new` | 100 | 20.000.000 |
|
||||
| E-Wallet | DANA | `DANA` | `dana` | 100 | 20.000.000 |
|
||||
| E-Wallet | ShopeePay | `SHOPEEPAY` | `shopee-pay` | 1 | 20.000.000 |
|
||||
| E-Wallet | LinkAja | `LINKAJA` | `linkaja` | 1 | 20.000.000 |
|
||||
| E-Wallet | AstraPay | `ASTRAPAY` | `astra-pay` | 100 | 20.000.000 |
|
||||
| Virtual Account | BCA VA | `BCA_VIRTUAL_ACCOUNT` | `bca` | 10.000 | 50.000.000 |
|
||||
| Virtual Account | Mandiri VA | `MANDIRI_VIRTUAL_ACCOUNT` | `mandiri` | 1 | 50.000.000.000 |
|
||||
| Virtual Account | BNI VA | `BNI_VIRTUAL_ACCOUNT` | `bni` | 1 | 50.000.000 |
|
||||
| Virtual Account | BRI VA | `BRI_VIRTUAL_ACCOUNT` | `bri` | 1 | 50.000.000.000 |
|
||||
| Virtual Account | BSI VA | `BSI_VIRTUAL_ACCOUNT` | `bsi` | 1 | 50.000.000.000 |
|
||||
| Virtual Account | Permata VA | `PERMATA_VIRTUAL_ACCOUNT` | `permata` | 1 | 9.999.999.999 |
|
||||
| Virtual Account | CIMB VA | `CIMB_VIRTUAL_ACCOUNT` | `cimb-niaga` | 1 | 50.000.000 |
|
||||
| Virtual Account | BJB VA | `BJB_VIRTUAL_ACCOUNT` | `bank-bjb` | 1 | 2.000.000.000 |
|
||||
| Virtual Account | BSS VA | `BSS_VIRTUAL_ACCOUNT` | `bank-sahabat-sampoerna` | 1 | 50.000.000.000 |
|
||||
| Outlet | Alfamart | `ALFAMART` | `alfamart` | 10.000 | 5.000.000 |
|
||||
| Outlet | Indomaret | `INDOMARET` | `indomaret` | 10.000 | 2.500.000 |
|
||||
| Kartu Kredit | Kartu Kredit | `CARDS` | `visa,mastercard,jcb` | 5.000 | 200.000.000 |
|
||||
|
||||
GoPay is gone from the seed (Xendit Invoice API doesn't expose a GoPay
|
||||
channel; was seeded inactive previously, now skipped entirely on fresh DBs).
|
||||
|
||||
## 7. Customer redirect pages
|
||||
|
||||
New brand-styled HTML pages served by the backend at:
|
||||
- `GET /payment/return/success`
|
||||
- `GET /payment/return/failure`
|
||||
|
||||
Implementation: [payment-return.routes.js](../backend/src/routes/public/payment-return.routes.js).
|
||||
Single template function, ~140 lines, brand colors mirror `halo_tokens.dart`
|
||||
(pink #E17A9D for success, red #D86B6B for failure). Bricolage Grotesque
|
||||
headline + Poppins body via Google Fonts. Success/failure glyph in a soft
|
||||
circle, headline, body copy, "Buka HaloBestie" CTA button firing
|
||||
`halobestie://payment/return?status=…`, soft hint "atau tutup tab ini".
|
||||
|
||||
Set in `.env`:
|
||||
|
||||
```
|
||||
XENDIT_SUCCESS_REDIRECT_URL=http://192.168.88.247:3000/payment/return/success
|
||||
XENDIT_FAILURE_REDIRECT_URL=http://192.168.88.247:3000/payment/return/failure
|
||||
```
|
||||
|
||||
For prod swap the host: `https://api.halobestie.com/payment/return/...`.
|
||||
|
||||
## 8. `halobestie://` URL scheme
|
||||
|
||||
Registered on both platforms so the CTA button on the redirect pages brings
|
||||
the app to foreground when tapped from Chrome Custom Tab / browser.
|
||||
|
||||
**Android** ([AndroidManifest.xml](../client_app/android/app/src/main/AndroidManifest.xml)) —
|
||||
new `<intent-filter android:autoVerify="false">` on `.MainActivity` with
|
||||
`VIEW + DEFAULT + BROWSABLE + scheme="halobestie"`. `BROWSABLE` is **mandatory**
|
||||
for browser/Custom Tab link resolution — without it, taps from a browser go
|
||||
nowhere.
|
||||
|
||||
**iOS** ([Info.plist](../client_app/ios/Runner/Info.plist)) — `CFBundleURLTypes`
|
||||
array with `CFBundleURLName=com.mybestie` + `CFBundleURLSchemes=["halobestie"]`.
|
||||
|
||||
**No Flutter URI consumption today.** The waiting-payment screen's poller
|
||||
already detects payment confirmation independently via `GET /payment-requests/:id`.
|
||||
The deeplink just bounces the activity to the foreground; the singleTop launch
|
||||
mode reuses the existing instance. If we need to react to URI params later
|
||||
(e.g. to deep-link past the waiting screen), add the `app_links` package and
|
||||
listen to its stream from `AuthBridge` or main.
|
||||
|
||||
**Gotcha:** manifest/plist changes don't hot-reload. Stop `flutter run` (`q`)
|
||||
and re-run, or `flutter build apk --debug` + reinstall.
|
||||
|
||||
## 9. Tests + verification
|
||||
|
||||
- 169/169 backend tests pass after the revamp.
|
||||
- Flutter analyze clean (pre-existing `MyApp` widget_test.dart error untouched).
|
||||
- Manual smoke: backend public routes verified via `curl`:
|
||||
- `/assets/payment-icons/qris.svg` → 200, `image/svg+xml`, 914 bytes
|
||||
- `/payment/return/success` → 200, 3102 bytes
|
||||
- `/payment/return/failure` → 200, 3117 bytes
|
||||
- `/api/client/payment-methods` → 401 (auth required, expected)
|
||||
|
||||
## What's still pending before `XENDIT_ENABLED=true`
|
||||
|
||||
See [[project-xendit-integration-next]] §"Status update 2026-05-27" for the
|
||||
full pickup checklist. Headline blockers:
|
||||
|
||||
1. **Webhook URL routing** — Xendit calls one URL per env; existing app owns
|
||||
the prod one. Three options on the table (separate dev account, external
|
||||
router on existing URL via `metadata.app="halobestie_v2"`, or self-poll
|
||||
script). User to decide.
|
||||
2. **client_app `pairing_notifier.dart:166`** — remove legacy `POST /chat/request`.
|
||||
3. **client_app `session_closure_notifier.dart:75`** — extension flow Custom Tab.
|
||||