From 280f66190a51e1576fc5dda62bc49a1284ce42fe Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Fri, 4 Jul 2025 00:07:50 +0900 Subject: [PATCH 01/20] initial --- src/lib/components/GlobalSidebar.svelte | 148 +++++++++++++++++ src/lib/components/Header.svelte | 151 +++++++++++++++++- .../dashboard/LocationSidebar.svelte | 12 -- src/lib/stores/SidebarStore.svelte.ts | 99 ++++++++++++ src/routes/+layout.svelte | 31 +++- src/routes/app/dashboard/+page.svelte | 68 ++++---- 6 files changed, 458 insertions(+), 51 deletions(-) create mode 100644 src/lib/components/GlobalSidebar.svelte create mode 100644 src/lib/stores/SidebarStore.svelte.ts diff --git a/src/lib/components/GlobalSidebar.svelte b/src/lib/components/GlobalSidebar.svelte new file mode 100644 index 00000000..0b647b98 --- /dev/null +++ b/src/lib/components/GlobalSidebar.svelte @@ -0,0 +1,148 @@ + + + +{#if sidebarStore.isOpen} +
sidebarStore.close()} + aria-hidden="true" + >
+{/if} + + + + + diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 32e0f2f2..d6fda34f 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -9,6 +9,7 @@ import ThemeToggle from './theme/ThemeToggle.svelte'; import Button from './UI/buttons/Button.svelte'; import MaterialIcon from './UI/icons/MaterialIcon.svelte'; + import { sidebarStore } from '$lib/stores/SidebarStore.svelte'; let { userName } = $props(); @@ -23,6 +24,11 @@ announcementVisible = false; } + // Toggle sidebar when hamburger menu is clicked + function toggleSidebar() { + sidebarStore.toggle(); + } + // Close mobile menu when clicking outside function handleClickOutside(event) { if (!event.target.closest('.mobile-menu') && !event.target.closest('.mobile-menu-btn')) { @@ -118,7 +124,7 @@ + + + {#if mobileMenuOpen} +
+
+ + + + +
+ +
+ + Theme + + +
+ + +
+ + Language + + +
+ + + + + + + + Help + + + +
+ {#if page.data.session?.user} + + {:else} + + {/if} +
+
+
+
+ {/if} diff --git a/src/lib/components/dashboard/LocationSidebar.svelte b/src/lib/components/dashboard/LocationSidebar.svelte index 758a722f..8448af96 100644 --- a/src/lib/components/dashboard/LocationSidebar.svelte +++ b/src/lib/components/dashboard/LocationSidebar.svelte @@ -202,7 +202,6 @@ >
- - {#if !collapsed}

{$_('Locations')}

@@ -232,10 +230,8 @@ {/if}
-
{#if collapsed} - {:else} -
@@ -278,7 +273,6 @@ {/if}
- -
- - -
- -
-
- -
-
-

Total Locations

-

{data?.locations?.length || 0}

-
-
-
- - -
-
- -
-
-

Total Devices

-

- {(data?.locations || []).reduce((sum: number, loc: LocationWithDevices) => sum + (loc.cw_devices?.length || 0), 0)} -

-
-
-
- - -
-
- -
-
-

Active Locations

-

- {(locations || []).filter((loc: LocationWithDevices) => (loc.cw_devices?.length || 0) > 0).length} -

-
-
-
-
- - - -
-
-

Locations Data Grid

-
- -
- - -
-
- {processedData.length} locations • Click any row to view details -
-
-
- - {#if locations.length > 0} -
- -
-
- {#each columns as column} -
- {#if column.sortable} - - {:else} - {column.label} - {/if} -
- {/each} -
-
- - -
- {#each locations as loc, index} -
viewLocation(loc.location_id)} - role="button" - tabindex="0" - onkeydown={(e) => e.key === 'Enter' && viewLocation(loc.location_id)} - > - -
- - - {loc.name || 'Unnamed Location'} - -
- - -
- - {loc.description || 'No description'} - -
- - -
- {#if loc.lat && loc.long} -
-
{loc.lat.toFixed(4)}
-
{loc.long.toFixed(4)}
-
- {:else} - Not set - {/if} -
- - -
- 0 ? 'text-green-600 dark:text-green-400' : 'text-gray-400'} - size="1em" - /> - 0 ? 'font-semibold text-green-600 dark:text-green-400' : 'text-gray-400'}> - {loc.deviceCount} - -
- - -
- - {new Date(loc.created_at).toLocaleDateString()} - -
- - -
- - -
-
- {/each} -
-
- {:else if searchTerm.trim()} - -
- -

No locations found

-

No locations match your search for "{searchTerm}"

- -
- {:else} - -
- -

No locations found

-

Get started by creating your first location

- -
- {/if} -
-
+
+ +
+
+

All Locations

+

+ Manage and view all your monitoring locations +

+
+ + +
+ + +
+ +
+
+ +
+
+

Total Locations

+

+ {data?.locations?.length || 0} +

+
+
+
+ + +
+
+ +
+
+

Total Devices

+

+ {(data?.locations || []).reduce( + (sum: number, loc: LocationWithDevices) => sum + (loc.cw_devices?.length || 0), + 0 + )} +

+
+
+
+ + +
+
+ +
+
+

Active Locations

+

+ {(locations || []).filter( + (loc: LocationWithDevices) => (loc.cw_devices?.length || 0) > 0 + ).length} +

+
+
+
+
+ + + +
+
+

Locations Data Grid

+
+ +
+ + +
+
+ {processedData.length} locations • Click any row to view details +
+
+
+ + {#if locations.length > 0} +
+ +
+
+ {#each columns as column} +
+ {#if column.sortable} + + {:else} + {column.label} + {/if} +
+ {/each} +
+
+ + +
+ {#each locations as loc, index} +
viewLocation(loc.location_id)} + role="button" + tabindex="0" + onkeydown={(e) => e.key === 'Enter' && viewLocation(loc.location_id)} + > + +
+ + + {loc.name || 'Unnamed Location'} + +
+ + +
+ + {loc.description || 'No description'} + +
+ + +
+ {#if loc.lat && loc.long} +
+
{loc.lat.toFixed(4)}
+
{loc.long.toFixed(4)}
+
+ {:else} + Not set + {/if} +
+ + +
+ 0 + ? 'text-green-600 dark:text-green-400' + : 'text-gray-400'} + size="1em" + /> + 0 + ? 'font-semibold text-green-600 dark:text-green-400' + : 'text-gray-400'} + > + {loc.deviceCount} + +
+ + +
+ + {new Date(loc.created_at).toLocaleDateString()} + +
+ + +
+ + +
+
+ {/each} +
+
+ {:else if searchTerm.trim()} + +
+ +

No locations found

+

No locations match your search for "{searchTerm}"

+ +
+ {:else} + +
+ +

No locations found

+

Get started by creating your first location

+ +
+ {/if} +
+
\ No newline at end of file + /* Custom table styling */ + .table-container { + scrollbar-width: thin; + scrollbar-color: rgb(156 163 175) transparent; + } + + .table-container::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + .table-container::-webkit-scrollbar-track { + background: transparent; + } + + .table-container::-webkit-scrollbar-thumb { + background-color: rgb(156 163 175); + border-radius: 3px; + } + + .table-container::-webkit-scrollbar-thumb:hover { + background-color: rgb(107 114 128); + } + From d4e0683382ff8fae02d2be8d8a74ce8c0d3ccea3 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Fri, 4 Jul 2025 11:32:56 +0900 Subject: [PATCH 05/20] sidebar open/close works,but no text is displayed --- src/lib/components/GlobalSidebar.svelte | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/lib/components/GlobalSidebar.svelte b/src/lib/components/GlobalSidebar.svelte index 8114fce6..a39d750e 100644 --- a/src/lib/components/GlobalSidebar.svelte +++ b/src/lib/components/GlobalSidebar.svelte @@ -102,7 +102,7 @@ {/if} - - {sidebarStore.isOpen} - {#if sidebarStore.isOpen} + diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index 846469a3..db47e68e 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -42,18 +42,11 @@
- -
diff --git a/src/routes/app/all-devices/+page.server.ts b/src/routes/app/all-devices/+page.server.ts new file mode 100644 index 00000000..fee8ac43 --- /dev/null +++ b/src/routes/app/all-devices/+page.server.ts @@ -0,0 +1,21 @@ +import { redirect, fail } from '@sveltejs/kit'; +import { SessionService } from '$lib/services/SessionService'; +import { ErrorHandlingService } from '$lib/errors/ErrorHandlingService'; +import { DeviceRepository } from '$lib/repositories/DeviceRepository'; +import { DeviceService } from '$lib/services/DeviceService'; +import type { PageServerLoad } from '../../$types'; + +export const load: PageServerLoad = async ({ locals: { supabase } }) => { + const sessionService = new SessionService(supabase); + const { session, user } = await sessionService.getSafeSession(); + if (!session || !user) { + throw redirect(302, '/auth/login'); + } + + const errorHandler = new ErrorHandlingService(); + const deviceRepository = new DeviceRepository(supabase, errorHandler); + const deviceService = new DeviceService(deviceRepository); + const allDevicesPromise = deviceService.getAllDevices(); + + return { allDevicesPromise }; +}; diff --git a/src/routes/app/all-devices/+page.svelte b/src/routes/app/all-devices/+page.svelte new file mode 100644 index 00000000..d6afdd4b --- /dev/null +++ b/src/routes/app/all-devices/+page.svelte @@ -0,0 +1,42 @@ + + +
+ +
+
+

All Devices

+

+ Manage and view all your monitoring locations +

+
+
+
+ +{#await allDevicesPromise} +

Loading devices...

+{:then devices} +
+ {#if devices.length > 0} +
    + {#each devices as device} +
  • +

    {device.name}

    +

    ID: {device.id}

    +

    Location: {device.location}

    +
  • + {/each} +
+ {:else} +

No devices found.

+ {/if} +
+{:catch error} +

Error loading devices: {error.message}

+{/await} +
{JSON.stringify(allDevicesPromise, null, 2)}
From ae7b4f25580e72cd094da09f1b4dfc9c0dd634cc Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Fri, 4 Jul 2025 12:15:36 +0900 Subject: [PATCH 07/20] safety --- src/routes/app/all-devices/+page.svelte | 347 ++++++++++++++++++++++-- 1 file changed, 324 insertions(+), 23 deletions(-) diff --git a/src/routes/app/all-devices/+page.svelte b/src/routes/app/all-devices/+page.svelte index d6afdd4b..6a290bd6 100644 --- a/src/routes/app/all-devices/+page.svelte +++ b/src/routes/app/all-devices/+page.svelte @@ -1,9 +1,25 @@ -
@@ -15,28 +31,313 @@ Manage and view all your monitoring locations

+
+
+ + +
+
-
-{#await allDevicesPromise} -

Loading devices...

-{:then devices} -
- {#if devices.length > 0} -
    - {#each devices as device} -
  • -

    {device.name}

    -

    ID: {device.id}

    -

    Location: {device.location}

    -
  • + {#await allDevicesPromise} +
    +

    Loading devices...

    +
    + {:then devices} + {@const filteredDevices = filterDevices(devices)} +
    + {#if filteredDevices.length > 0} + {#each filteredDevices as device, index (device.device_id || device.dev_eui || device.name || index)} +
    +
    +
    + +
    +

    {device.name || device.dev_eui || 'Unnamed Device'}

    + +
    +
    +
    + {#if device.device_id || device.dev_eui} +
    + + ID: {device.device_id || device.dev_eui || 'N/A'} +
    + {/if} + {#if device.location_id} +
    + + Location ID: {device.location_id} +
    + {/if} + {#if device.cw_device_type?.name} +
    + + Type: {device.cw_device_type.name} +
    + {/if} +
    +
    +
    {/each} -
- {:else} -

No devices found.

- {/if} -
-{:catch error} -

Error loading devices: {error.message}

-{/await} -
{JSON.stringify(allDevicesPromise, null, 2)}
+ {:else} +
+ +

+ {searchTerm ? 'No devices match your search.' : 'No devices found.'} +

+
+ {/if} +
+ {:catch error} +
+

Error loading devices: {error.message}

+
+ {/await} + + + From 86a2a6b3d79fafcccfc1c1a0b4951c9d9b5e48e1 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Fri, 4 Jul 2025 12:19:44 +0900 Subject: [PATCH 08/20] safety --- src/routes/app/all-devices/+page.svelte | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/routes/app/all-devices/+page.svelte b/src/routes/app/all-devices/+page.svelte index 6a290bd6..9d92cb90 100644 --- a/src/routes/app/all-devices/+page.svelte +++ b/src/routes/app/all-devices/+page.svelte @@ -62,7 +62,9 @@
-

{device.name || device.dev_eui || 'Unnamed Device'}

+

+ {device.name || device.dev_eui || 'Unnamed Device'} +

+ + + + + + + + + Delete {report.name}? + This action cannot be undone. +
+ Cancel + deleteReport(report.id)} + class="rounded bg-red-600 px-3 py-1 text-white">Delete +
+
+
+
+ + +
+
+
+ + Created: {formatDate(report.created_at)} +
+ {#if report.dev_eui} +
+ + Device: {report.dev_eui} +
+ {/if} + {#if report.recipients} +
+ + Recipients: {report.recipients} +
+ {/if} +
+
+ + {/each} + {:else} +
+ +

+ {searchTerm ? 'No reports match your search.' : 'No reports found.'} +

+ {#if !searchTerm} + + + Create Your First Report + + {/if} +
+ {/if} + + + From 7484dbf614d3a1260c3a3da9e27a350389ad76a0 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Fri, 4 Jul 2025 13:52:17 +0900 Subject: [PATCH 10/20] removed text style on sidebar menu items --- src/lib/components/GlobalSidebar.svelte | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/lib/components/GlobalSidebar.svelte b/src/lib/components/GlobalSidebar.svelte index eb9acd55..4f258cb3 100644 --- a/src/lib/components/GlobalSidebar.svelte +++ b/src/lib/components/GlobalSidebar.svelte @@ -94,7 +94,7 @@ {#if isOpen}
sidebarStore.close()} aria-hidden="true" >
@@ -105,8 +105,8 @@ class="fixed top-0 left-0 z-50 flex flex-col border-r transition-all duration-300 ease-in-out {getDarkMode() ? 'border-slate-700/30 bg-slate-800/95' : 'border-gray-200/30 bg-white/95'} shadow-lg backdrop-blur-sm" - style="top: 119px; - height: calc(100vh - 119px); + style="top: 73px; + height: calc(100vh - 73px); width: {isOpen ? '256px' : '64px'};" class:mobile-hidden={!isOpen} aria-label="Sidebar navigation" @@ -122,8 +122,8 @@ {$_('Navigation')} + - - - - - diff --git a/src/routes/app/account-settings/display-settings/+page.svelte b/src/routes/app/account-settings/display-settings/+page.svelte new file mode 100644 index 00000000..9a1374f4 --- /dev/null +++ b/src/routes/app/account-settings/display-settings/+page.svelte @@ -0,0 +1,227 @@ + + +
+ +
+
+

+ 🌍 {$_('Units & Display Settings')} +

+

+ {$_('Choose your preferred units of measurement and display options for your account.')} +

+
+
+
+ + +
+ +
+

+ {$_('Unit Preferences')} +

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+
+
+ + +
+

+ {$_('Display Preferences')} +

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+
+
+
+
diff --git a/src/routes/app/account-settings/general/+page.server.ts b/src/routes/app/account-settings/general/+page.server.ts new file mode 100644 index 00000000..af0d8f36 --- /dev/null +++ b/src/routes/app/account-settings/general/+page.server.ts @@ -0,0 +1,53 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => { + const { session } = await safeGetSession(); + if (!session) { + redirect(303, '/'); + } + const { data: profile } = await supabase + .from('profiles') + .select(`username, full_name, website, avatar_url`) + .eq('id', session.user.id) + .single(); + return { session, profile }; +}; +export const actions: Actions = { + update: async ({ request, locals: { supabase, safeGetSession } }) => { + const formData = await request.formData(); + const fullName = formData.get('fullName') as string; + const username = formData.get('username') as string; + const website = formData.get('website') as string; + const avatarUrl = formData.get('avatarUrl') as string; + const { session } = await safeGetSession(); + const { error } = await supabase.from('profiles').upsert({ + id: session?.user.id, + full_name: fullName, + username, + website, + avatar_url: avatarUrl, + updated_at: new Date() + }); + if (error) { + return fail(500, { + fullName, + username, + website, + avatarUrl + }); + } + return { + fullName, + username, + website, + avatarUrl + }; + }, + signout: async ({ locals: { supabase, safeGetSession } }) => { + const { session } = await safeGetSession(); + if (session) { + await supabase.auth.signOut(); + redirect(303, '/'); + } + } +}; diff --git a/src/routes/app/account-settings/general/+page.svelte b/src/routes/app/account-settings/general/+page.svelte new file mode 100644 index 00000000..6287a8c2 --- /dev/null +++ b/src/routes/app/account-settings/general/+page.svelte @@ -0,0 +1,206 @@ + + + + {$_('General Account Settings')} + + +
+ +
+
+

+ ⚙️ {$_('General Account Settings')} +

+

+ {$_('Settings that affect your entire account, including your profile and preferences.')} +

+
+
+
+ + +
+ +
+
+

+ {$_('Profile Information')} +

+ +
+ +
+ { + profileForm.requestSubmit(); + }} + /> +
+

+ {$_('Profile Picture')} +

+

+ {$_('Click to upload a new profile picture')} +

+
+
+ + +
+ + +

+ {$_('Your email address cannot be changed')} +

+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+
+
+
+ + +
+
+

+ {$_('Account Actions')} +

+ +
+ +
+ +
+ + +
+ {$_('Need help with your account?')} + + {$_('Contact support')} + +
+
+
+
+
+
diff --git a/src/routes/app/account-settings/payment/+page.server.ts b/src/routes/app/account-settings/payment/+page.server.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/routes/app/account-settings/payment/+page.svelte b/src/routes/app/account-settings/payment/+page.svelte new file mode 100644 index 00000000..d902ccfa --- /dev/null +++ b/src/routes/app/account-settings/payment/+page.svelte @@ -0,0 +1,322 @@ + + +
+ +
+
+

💳 Account Payment Settings

+

+ Add or update your payment method securely with Stripe. +

+
+
+ + + ➡️ Add Subscription + + + +
+ +
+

+ 📋 Active Subscriptions +

+

+ Manage your active subscriptions and assign devices. +

+ + {#if subscriptions.length === 0} +
+ + + +

No subscriptions

+

+ You don't have any active subscriptions. +

+
+ {:else} +
+ {#each subscriptions as subscription (subscription.id)} +
+
+
+

+ {subscription.name} +

+
+ + ${subscription.amount}/{subscription.interval} + + + {subscription.status} + + {#if subscription.pausedUntil} + + Paused until {subscription.pausedUntil} + + {/if} +
+

+ Next billing: {subscription.current_period_end} +

+
+
+ +
+ +
+ +
+ +
+ + + +
+
+
+ {/each} +
+ {/if} +
+
+
diff --git a/src/routes/app/account-settings/payment/PauseSubscription.svelte b/src/routes/app/account-settings/payment/PauseSubscription.svelte new file mode 100644 index 00000000..a0dae1e8 --- /dev/null +++ b/src/routes/app/account-settings/payment/PauseSubscription.svelte @@ -0,0 +1,84 @@ + + + +
+

+ ⏸️ Pause All Subscriptions +

+

+ Temporarily pause all your active subscriptions. You won't be charged during the pause period. +

+ +
+
+ + +
+ + +
+
diff --git a/src/routes/app/account-settings/payment/add-subscription/+page.server.ts b/src/routes/app/account-settings/payment/add-subscription/+page.server.ts new file mode 100644 index 00000000..200c7d72 --- /dev/null +++ b/src/routes/app/account-settings/payment/add-subscription/+page.server.ts @@ -0,0 +1,187 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { Actions, PageServerLoad } from './$types'; +import Stripe from 'stripe'; +import { PRIVATE_STRIPE_SECRET_KEY } from '$env/static/private'; +import { PUBLIC_DOMAIN } from '$env/static/public'; + +// This is your test secret API key. +const stripe = new Stripe(PRIVATE_STRIPE_SECRET_KEY, { + apiVersion: '2025-06-30.basil', // Use the latest API version or a specific one + typescript: true +}); + +const YOUR_DOMAIN = PUBLIC_DOMAIN; + +export const load: PageServerLoad = async () => { + try { + // Fetch all active subscription products with their prices + const products = await stripe.products.list({ + active: true, + expand: ['data.default_price'] + }); + + // Fetch all prices for subscription products + const prices = await stripe.prices.list({ + active: true, + type: 'recurring', + expand: ['data.product'] + }); + + // Group prices by product + const subscriptionProducts = products.data + .filter((product) => product.type === 'service' || product.type === 'good') + .map((product) => { + const productPrices = prices.data.filter( + (price) => typeof price.product === 'object' && price.product.id === product.id + ); + + return { + id: product.id, + name: product.name, + description: product.description, + images: product.images, + metadata: product.metadata, + prices: productPrices.map((price) => ({ + id: price.id, + unit_amount: price.unit_amount, + currency: price.currency, + recurring: price.recurring, + lookup_key: price.lookup_key, + nickname: price.nickname + })) + }; + }) + .filter((product) => product.prices.length > 0); // Only include products with prices + + return { + subscriptionProducts + }; + } catch (error) { + console.error('Error loading subscription products:', error); + return { + subscriptionProducts: [], + error: 'Failed to load subscription products' + }; + } +}; + +export const actions: Actions = { + 'create-checkout-session': async ({ request, url, locals }) => { + const formData = await request.formData(); + const price_id = formData.get('price_id') as string; + + try { + // Get the current user + const { session: userSession, user } = await locals.safeGetSession(); + + if (!userSession || !user) { + return { + error: 'You must be logged in to create a subscription' + }; + } + + // Create or retrieve customer + let customerId; + try { + const customers = await stripe.customers.list({ + email: user.email, + limit: 1 + }); + + if (customers.data.length > 0) { + customerId = customers.data[0].id; + } else { + const customer = await stripe.customers.create({ + email: user.email, + name: + user.user_metadata?.full_name || + `${user.user_metadata?.first_name || ''} ${user.user_metadata?.last_name || ''}`.trim(), + metadata: { + user_id: user.id + } + }); + customerId = customer.id; + } + } catch (customerError) { + console.error('Error handling customer:', customerError); + return { + error: 'Failed to create or retrieve customer' + }; + } + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + billing_address_collection: 'auto', + line_items: [ + { + price: price_id, + quantity: 1 + } + ], + mode: 'subscription', + success_url: `${YOUR_DOMAIN}/app/account-settings/payment/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${YOUR_DOMAIN}/app/account-settings/payment/cancel`, + metadata: { + user_id: user.id + } + }); + + if (session.url) { + return { + success: true, + redirectUrl: session.url + }; + } else { + return { + error: 'No session URL returned from Stripe' + }; + } + } catch (error) { + console.error('Error creating checkout session:', error); + return { + error: 'Failed to create checkout session' + }; + } + }, + + 'create-portal-session': async ({ request }) => { + const formData = await request.formData(); + const session_id = formData.get('session_id') as string; + + try { + // For demonstration purposes, we're using the Checkout session to retrieve the customer ID. + // Typically this is stored alongside the authenticated user in your database. + const checkoutSession = await stripe.checkout.sessions.retrieve(session_id); + + if (!checkoutSession.customer) { + return { + error: 'No customer found in checkout session' + }; + } + + // This is the url to which the customer will be redirected when they're done + // managing their billing with the portal. + const returnUrl = `${YOUR_DOMAIN}/app/account-settings/payment`; + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: checkoutSession.customer as string, + return_url: returnUrl + }); + + return { + success: true, + redirectUrl: portalSession.url + }; + } catch (error) { + console.error('Error creating portal session:', error); + return { + error: 'Failed to create portal session' + }; + } + } +}; + +// Note: Webhook handling should be moved to a separate API route +// Create /src/routes/api/webhook/+server.ts for webhook handling +// This is because webhooks need raw body access and special headers handling diff --git a/src/routes/app/account-settings/payment/add-subscription/+page.svelte b/src/routes/app/account-settings/payment/add-subscription/+page.svelte new file mode 100644 index 00000000..c149fd23 --- /dev/null +++ b/src/routes/app/account-settings/payment/add-subscription/+page.svelte @@ -0,0 +1,139 @@ + + + + Add Subscription - CropWatch + + +
+

Add Subscription

+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if data.error} +
+ {data.error} +
+ {/if} + +
+ {#if data.subscriptionProducts.length === 0} +
+

No subscription products available at this time.

+
+ {:else} +
+ {#each data.subscriptionProducts as product} +
+ {#if product.images.length > 0} + {product.name} + {/if} + +
+ + + + + + + + +
+

{product.name}

+ {#if product.description} +

{product.description}

+ {/if} +
+
+ + +
+ {#each product.prices as price} +
+
+ + {formatPrice(price.unit_amount, price.currency)} + {formatBillingPeriod(price.recurring)} + + {#if price.nickname} + {price.nickname} + {/if} +
+ +
{ + return async ({ result }) => { + if ( + result.type === 'success' && + result.data && + 'redirectUrl' in result.data && + result.data.redirectUrl + ) { + // Redirect to Stripe Checkout + window.location.href = result.data.redirectUrl as string; + } + }; + }} + > + + +
+
+ {/each} +
+
+ {/each} +
+ {/if} +
+
diff --git a/src/routes/app/account-settings/payment/cancel/+page.svelte b/src/routes/app/account-settings/payment/cancel/+page.svelte new file mode 100644 index 00000000..8144a032 --- /dev/null +++ b/src/routes/app/account-settings/payment/cancel/+page.svelte @@ -0,0 +1,28 @@ + + Payment Cancelled - CropWatch + + +
+
+
+

Payment Cancelled

+

Your subscription setup was cancelled. No charges were made.

+
+ + +
+
diff --git a/src/routes/app/account-settings/payment/success/+page.svelte b/src/routes/app/account-settings/payment/success/+page.svelte new file mode 100644 index 00000000..16cad8ad --- /dev/null +++ b/src/routes/app/account-settings/payment/success/+page.svelte @@ -0,0 +1,48 @@ + + + + Payment Success - CropWatch + + +
+
+
+

Payment Successful!

+

Your subscription has been created successfully.

+
+ + {#if sessionId} +
+

Manage Your Subscription

+
+ + +
+
+ {/if} + + + Back to Payment Settings + +
+
diff --git a/src/routes/app/all-gateways/+page.svelte b/src/routes/app/all-gateways/+page.svelte new file mode 100644 index 00000000..3a5727c2 --- /dev/null +++ b/src/routes/app/all-gateways/+page.svelte @@ -0,0 +1,24 @@ + + +
+ +
+
+

All Gateways

+

Watch the status of all your gateways

+
+
+
+ +
+
+
+ +
diff --git a/src/routes/app/all-notifications/+page.svelte b/src/routes/app/all-notifications/+page.svelte new file mode 100644 index 00000000..6d356d5f --- /dev/null +++ b/src/routes/app/all-notifications/+page.svelte @@ -0,0 +1,33 @@ + + +
+ +
+
+

All Notifications

+

+ Search and manage all your notifications across all devices +

+
+
+
+ +
+
+
+ +
diff --git a/src/routes/app/all-reports/+page.svelte b/src/routes/app/all-reports/+page.svelte index c432bfce..70b61979 100644 --- a/src/routes/app/all-reports/+page.svelte +++ b/src/routes/app/all-reports/+page.svelte @@ -79,14 +79,6 @@ /> - - - Create Report - @@ -180,12 +172,6 @@

{searchTerm ? 'No reports match your search.' : 'No reports found.'}

- {#if !searchTerm} - - - Create Your First Report - - {/if} {/if} diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte index 96918880..3c7e4374 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte @@ -24,6 +24,7 @@ import CsvDownloadButton from '$lib/csv/CsvDownloadButton.svelte'; import { setupRealtimeSubscription } from './realtime.svelte'; import type { RealtimeChannel } from '@supabase/supabase-js'; + import { DateTime } from 'luxon'; // Get device data from server load function let { data }: PageProps = $props(); @@ -100,7 +101,6 @@ }); $effect(() => { - debugger; if (device.cw_device_type?.data_table_v2 && !channel) { channel = setupRealtimeSubscription( data.supabase, @@ -211,7 +211,7 @@ }; // Function to handle fetching data for a specific date range - async function handleDateRangeSubmit() { + async function handleDateRangeSubmit(units?: number) { if (!startDateInputString || !endDateInputString) { deviceDetail.error = 'Please select both start and end dates.'; return; @@ -234,6 +234,27 @@ loadingHistoricalData = true; deviceDetail.error = null; // Clear previous errors before fetching + // If units is provided, slide the date range forward or backward + if (units !== undefined) { + debugger; + //get range between start and end dates + let endDateTime = DateTime.fromJSDate(finalEndDate); + let startDateTime = DateTime.fromJSDate(finalStartDate); + const diffInDays = Math.round(Math.abs(startDateTime.diff(endDateTime, ['days']).days)); + if (units < 0) { + // Slide back + startDateTime = startDateTime.minus({ days: diffInDays }); + endDateTime = endDateTime.minus({ days: diffInDays }); + } else if (units > 0) { + // Slide forward + startDateTime.plus({ days: diffInDays }).toJSDate(); + endDateTime.plus({ days: diffInDays }).toJSDate(); + } + // update input strings to reflect the new range + startDateInputString = formatDateForInput(startDateTime.toJSDate()); + endDateInputString = formatDateForInput(endDateTime.toJSDate()); + } + const newData = await fetchDataForDateRange(device, finalStartDate, finalEndDate); //console.log('Requested range:', finalStartDate, finalEndDate, 'Received:', newData); if (newData) { @@ -268,26 +289,45 @@ - +
+ + +
- +
+ + +
@@ -305,7 +307,6 @@
- -
{@render controls?.()}
{#if children} {@render children?.()} diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts index cb4219c1..4cec730e 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/device-detail.svelte.ts @@ -34,8 +34,33 @@ export function setupDeviceDetail() { let ApexCharts = $state(undefined); // Chart instances - let mainChartInstance: any = null; - let brushChartInstance: any = null; + const mainChartInstances: Record = {}; + const brushChartInstances: Record = {}; + + /** + * Gets all numeric keys from the historical data, excluding specified ignored keys. + * @param historicalData Historical data array. + * @param ignoredDataKeys Array of keys to ignore. + * @returns Array of numeric keys found in the data. + */ + function getNumericKeys( + historicalData: any[], + ignoredDataKeys: string[] = ['id', 'dev_eui', 'created_at'] + ): string[] { + if (!historicalData || !historicalData.length) { + return []; + } + + const sample = historicalData.find((row) => row && typeof row === 'object'); + + if (!sample) { + return []; + } + + return Object.keys(sample).filter( + (key) => !ignoredDataKeys.includes(key) && typeof sample[key] === 'number' + ); + } // Function to process historical data and calculate stats function processHistoricalData(historicalData: any[]) { @@ -51,10 +76,7 @@ export function setupDeviceDetail() { .reverse(); // Determine numeric keys dynamically (excluding dev_eui, created_at) - const excludeKeys = ['dev_eui', 'created_at']; - const numericKeys = Object.keys(historicalData[0] || {}).filter( - (key) => !excludeKeys.includes(key) && typeof historicalData[0][key] === 'number' - ); + const numericKeys = getNumericKeys(historicalData); // Reset chartData and stats for all numeric keys numericKeys.forEach((key) => { @@ -192,42 +214,47 @@ export function setupDeviceDetail() { } /** - * Renders a generic ApexCharts line chart with a brush (range selector) below. - * This function is fully generic: it will plot all numeric keys in the data (except for ignored keys). - * - * @param historicalData Array of objects (rows) with at least a 'created_at' timestamp and numeric fields - * @param dataType (Unused, for compatibility) - * @param latestData (Unused, for compatibility) - * @param chart1Element HTMLElement to render the main chart into - * @param chart1BrushElement HTMLElement to render the brush chart into - * @param _ignored (Unused, for compatibility) - * @param ignoredDataKeys Array of keys to ignore (default: ['id', 'dev_eui', 'created_at']) + * Renders a generic ApexCharts line chart with a brush (range selector) below. This function is + * fully generic: it will plot all numeric keys in the data (except for ignored keys). + * @param params + * @param params.historicalData Array of objects (rows) with at least a 'created_at' timestamp and + * numeric fields + * @param params.chart1Element HTMLElement to render the main chart into + * @param params.chart1BrushElement HTMLElement to render the brush chart into + * @param params.key Optional key to render a specific chart (if not provided, all numeric keys + * will be rendered) + * @param params.ignoredDataKeys Array of keys to ignore (default: ['id', 'dev_eui', + * 'created_at']) */ - async function renderVisualization( - historicalData: any[], - dataType: string, // ignored for charting - latestData: any, // ignored for charting - chart1Element: HTMLElement | undefined, - chart1BrushElement: HTMLElement | undefined, - _ignored?: any, // placeholder for removed dataGridElement - ignoredDataKeys: string[] = ['id', 'dev_eui', 'created_at'] - ) { + async function renderVisualization({ + historicalData, + chart1Element, + chart1BrushElement, + key = '_all', + ignoredDataKeys = ['id', 'dev_eui', 'created_at'] + }: { + historicalData: any[]; + chart1Element?: HTMLElement; + chart1BrushElement?: HTMLElement; + key?: string; + ignoredDataKeys?: string[]; + }) { if (!browser || !historicalData || historicalData.length === 0) return; await new Promise((resolve) => setTimeout(resolve, 50)); if (!chart1Element || !chart1BrushElement) return; // Destroy previous chart instances if they exist - if (mainChartInstance) { + if (mainChartInstances[key]) { try { - mainChartInstance.destroy(); + mainChartInstances[key].destroy(); } catch {} - mainChartInstance = null; + mainChartInstances[key] = null; } - if (brushChartInstance) { + if (brushChartInstances[key]) { try { - brushChartInstance.destroy(); + brushChartInstances[key].destroy(); } catch {} - brushChartInstance = null; + brushChartInstances[key] = null; } if (!ApexCharts) { @@ -235,15 +262,22 @@ export function setupDeviceDetail() { } // Find all numeric keys in the data (excluding ignored keys) - const sample = historicalData.find((row) => row && typeof row === 'object'); - if (!sample) return; - const numericKeys = Object.keys(sample).filter( - (key) => !ignoredDataKeys.includes(key) && typeof sample[key] === 'number' - ); + const numericKeys = getNumericKeys(historicalData, ignoredDataKeys); if (numericKeys.length === 0) return; + let keys = [...numericKeys]; + + if (key !== '_all') { + // Filter numeric keys based on the provided key + if (numericKeys.includes(key)) { + keys = [key]; + } else { + return; + } + } + // Build series for each numeric key - const series = numericKeys.map((key) => ({ + const series = keys.map((key) => ({ name: get(_)(key), data: historicalData .filter((row) => typeof row[key] === 'number' && row[key] !== null && row['created_at']) @@ -251,14 +285,17 @@ export function setupDeviceDetail() { })); series.forEach((s) => s.data.sort((a, b) => a.x - b.x)); - const colorMap = Object.fromEntries(numericKeys.map((key) => [key, getTextColorByKey(key)])); + const colorMap = Object.fromEntries(keys.map((key) => [key, getTextColorByKey(key)])); const colors = Object.values(colorMap); // Y-axis config for each series - const yaxis = numericKeys.map((key, idx) => ({ + const yaxis = keys.map((key, idx) => ({ seriesName: key, opposite: idx % 2 === 1, - title: { text: get(_)(key), style: { color: colorMap[key] } }, + title: + key === '_all' + ? { text: get(_)(key), style: { fontSize: '16px', color: colorMap[key] } } + : {}, labels: { style: { colors: colorMap[key] } } })); @@ -273,15 +310,15 @@ export function setupDeviceDetail() { const mainChartOptions = { series, chart: { - id: 'mainChart', + id: `chart-${key}-main`, type: 'line', - height: 350, + height: 200, toolbar: { autoSelected: 'pan', show: false }, animations: { enabled: false }, zoom: { enabled: false } }, colors, - stroke: { curve: 'smooth', width: numericKeys.map(() => 1) }, + stroke: { curve: 'smooth', width: keys.map(() => 1) }, dataLabels: { enabled: false }, markers: { size: 1, strokeWidth: 0, hover: { size: 4 } }, xaxis: { type: 'datetime', labels: { datetimeUTC: false } }, @@ -305,15 +342,15 @@ export function setupDeviceDetail() { const brushChartOptions = { series, chart: { - id: 'brushChart', - height: 150, + id: `chart-${key}-brush`, + height: 100, type: 'area', - brush: { target: 'mainChart', enabled: true }, + brush: { target: `chart-${key}-main`, enabled: true }, selection: { enabled: true, xaxis: { min: minDate, max: maxDate } } }, colors, fill: { type: 'gradient', gradient: { opacityFrom: 0.7, opacityTo: 0.3 } }, - stroke: { width: numericKeys.map(() => 1) }, + stroke: { width: keys.map(() => 1) }, xaxis: { type: 'datetime', tooltip: { enabled: false }, labels: { datetimeUTC: false } }, yaxis: { show: false, tickAmount: 2 }, grid: { borderColor: '#2a2a2a', strokeDashArray: 2, yaxis: { lines: { show: false } } }, @@ -321,23 +358,23 @@ export function setupDeviceDetail() { }; // Render charts - mainChartInstance = new ApexCharts(chart1Element, mainChartOptions); - brushChartInstance = new ApexCharts(chart1BrushElement, brushChartOptions); + mainChartInstances[key] = new ApexCharts(chart1Element, mainChartOptions); + brushChartInstances[key] = new ApexCharts(chart1BrushElement, brushChartOptions); try { - await mainChartInstance.render(); - await brushChartInstance.render(); + await mainChartInstances[key].render(); + await brushChartInstances[key].render(); } catch (err) { - if (mainChartInstance) { + if (mainChartInstances[key]) { try { - mainChartInstance.destroy(); + mainChartInstances[key].destroy(); } catch {} - mainChartInstance = null; + mainChartInstances[key] = null; } - if (brushChartInstance) { + if (brushChartInstances[key]) { try { - brushChartInstance.destroy(); + brushChartInstances[key].destroy(); } catch {} - brushChartInstance = null; + brushChartInstances[key] = null; } } } @@ -387,6 +424,7 @@ export function setupDeviceDetail() { // Functions formatDateForDisplay, hasValue, + getNumericKeys, processHistoricalData, fetchDataForDateRange, renderVisualization, diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte index 81a7b9b5..fbe80a53 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/settings/+page.svelte @@ -8,13 +8,19 @@ import TextInput from '$lib/components/UI/form/TextInput.svelte'; import Dialog from '$lib/components/UI/overlay/Dialog.svelte'; import { error, success } from '$lib/stores/toast.svelte.js'; - import { _, locale } from 'svelte-i18n'; + import { formatDateOnly } from '$lib/utilities/helpers.js'; + import { _ } from 'svelte-i18n'; let { data } = $props(); const device = $derived(data.device); const ownerId = $derived(data.ownerId); const isOwner = $derived(device?.user_id === ownerId); + // @todo Use a proper sensor datasheet link when wiki pages are created + const deviceLinkId = $derived( + device?.cw_device_type?.data_table_v2 === 'cw_soil_data' ? 'soil_sensors' : 'co2_sensors' + ); + let showDeleteDialog = $state(false); let devEui = page.params.devEui; @@ -42,7 +48,7 @@ - Device Settings - CropWatch + {$_('Device Settings')} - CropWatch
@@ -53,7 +59,7 @@
+
+ + {$_('EUI')} + + {device?.dev_eui || $_('Unknown')} +
+
+ + {$_('Installed Date')} + + {device?.installed_at ? formatDateOnly(device.installed_at) : $_('Unknown')} +
+
+ + {$_('Coordinates')} + + {#if device?.lat && device?.long} + + {device.lat}, {device.long} + {:else} + {$_('Unknown')} + {/if} +
+
+ {#if isOwner}
- + {#await data.locations} - Loading... + {$_('Loading...')} {:then locations} {#if isOwner} {:else} {locations.find((loc) => loc.location_id === device?.location_id)?.name || 'Unknown Location'} + {#if device?.location_id} + ({device.location_id}) + {/if} {/if} {/await}
- {#if isOwner} - - {/if} +
+ {#if isOwner} + + {/if} +
From 680164e70357f6107b8fc6f24094ee002d926d49 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Tue, 8 Jul 2025 11:11:33 +0900 Subject: [PATCH 20/20] minor style and icon updates --- src/lib/csv/CsvDownloadButton.svelte | 11 +++++++++-- src/lib/i18n/locales/en.ts | 4 ++-- src/lib/i18n/locales/ja.ts | 4 ++-- .../[location_id]/devices/[devEui]/+page.svelte | 12 +++++++++--- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/lib/csv/CsvDownloadButton.svelte b/src/lib/csv/CsvDownloadButton.svelte index ed78dd5c..4e394b72 100644 --- a/src/lib/csv/CsvDownloadButton.svelte +++ b/src/lib/csv/CsvDownloadButton.svelte @@ -2,6 +2,7 @@ import { _ } from 'svelte-i18n'; import { Dialog } from 'bits-ui'; import Button from '$lib/components/UI/buttons/Button.svelte'; + import MaterialIcon from '$lib/components/UI/icons/MaterialIcon.svelte'; let { devEui } = $props(); @@ -40,7 +41,10 @@ diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 0fb5a7d7..fe0cb635 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -73,8 +73,8 @@ export const strings = { load_selected_data: 'Load Selected Data', 'Load Selected Range': 'Load Selected Range', load_selected_range: 'Load Selected Range', - 'Download CSV': 'Download CSV', - download_csv: 'Download CSV', + 'Download CSV': 'CSV', + download_csv: 'CSV', 'Download Excel': 'Download Excel', download_excel: 'Download Excel', 'Download PDF': 'Download PDF', diff --git a/src/lib/i18n/locales/ja.ts b/src/lib/i18n/locales/ja.ts index f803b24e..9d3ea485 100644 --- a/src/lib/i18n/locales/ja.ts +++ b/src/lib/i18n/locales/ja.ts @@ -69,8 +69,8 @@ export const strings = { load_selected_data: '選択したデータを読み込む', 'Load Selected Range': '選択したデータを読み込む', load_selected_range: '選択したデータを読み込む', - 'Download CSV': 'CSVをダウンロード', - download_csv: 'CSVをダウンロード', + 'Download CSV': 'CSV', + download_csv: 'CSV', 'Download Excel': 'Excelをダウンロード', download_excel: 'Excelをダウンロード', 'Download PDF': 'PDFをダウンロード', diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte index 654e2e35..8805b528 100644 --- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte +++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte @@ -24,6 +24,7 @@ import { getDeviceDetailDerived, setupDeviceDetail } from './device-detail.svelte'; import Header from './Header.svelte'; import { setupRealtimeSubscription } from './realtime.svelte'; + import MaterialIcon from '$lib/components/UI/icons/MaterialIcon.svelte'; // Get device data from server load function let { data }: PageProps = $props(); @@ -300,8 +301,13 @@
- - + +